feat: mount chat API routes under /api/v2 (#28496)

## Stack context

This is the base of a 3-PR stack promoting the chat API from
`/api/experimental` to `/api/v2`: server compatibility mounts (this PR),
codersdk promotion (#28497), and frontend path updates (#28498).

## Summary

Double-mount the stable chat and MCP handlers under `/api/v2` while
retaining the existing experimental routes for the one-release
compatibility window decided in CODAGT-921. CODAGT-922 tracks removing
the compatibility mounts.

The shared route builders preserve existing authentication and
middleware behavior. Experiment-gated, debug, tombstone, and legacy
default-organization model routes remain experimental-only. Signed file
URLs, external OAuth callback URLs, and mixed-version replica relays
also remain on the experimental prefix during the transition.

Update Swagger and the generated API reference for the promoted routes,
including the workspace lookup and a runnable raw-body chat file upload
example. Retain internal endpoints outside the published reference,
share chat-file rate limits across both prefixes, enable CORS for the v2
MCP routes, and cover dual mounts plus exclusions with compatibility
tests. Remote dogfood UAT passed for the promoted chat, model, MCP, and
file flows.

> [!NOTE]
> Xum acted on Mike's behalf in this pull request.
<!-- xum-attribution: model=claude-fable-5 thinking=high -->
This commit is contained in:
Michael Suchacz
2026-08-26 16:17:06 +02:00
committed by GitHub
parent 0c0e605521
commit 351bb14032
27 changed files with 8686 additions and 4537 deletions
+32
View File
@@ -1,6 +1,7 @@
package coderdtest_test
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
@@ -10,6 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/apidoc"
"github.com/coder/coder/v2/coderd/coderdtest"
)
@@ -30,6 +32,36 @@ func TestEndpointsDocumented(t *testing.T) {
coderdtest.VerifySwaggerDefinitions(t, api.APIHandler, swaggerComments, coderdtest.WithSwaggerRoutePrefix("/api/v2"))
}
func TestChatModelPathParametersFormatted(t *testing.T) {
t.Parallel()
var swagger struct {
Paths map[string]map[string]struct {
Parameters []struct {
Name string `json:"name"`
In string `json:"in"`
Format string `json:"format"`
} `json:"parameters"`
} `json:"paths"`
}
require.NoError(t, json.Unmarshal([]byte(apidoc.SwaggerInfo.ReadDoc()), &swagger))
operations := swagger.Paths["/api/v2/organizations/{organization}/chats/models/{model}"]
for _, method := range []string{"get", "patch", "delete"} {
t.Run(method, func(t *testing.T) {
t.Parallel()
for _, parameter := range operations[method].Parameters {
if parameter.Name == "model" && parameter.In == "path" {
require.Equal(t, "uuid", parameter.Format)
return
}
}
require.Fail(t, "model path parameter not found")
})
}
}
func TestSDKFieldsFormatted(t *testing.T) {
t.Parallel()
+7 -4
View File
@@ -321,7 +321,7 @@ func assertGoCommentFirst(t *testing.T, comment SwaggerComment) {
text := strings.TrimSpace(line.Text)
if inSwaggerBlock {
if !strings.HasPrefix(text, "// @") && !strings.HasPrefix(text, "// nolint:") {
if text != "//" && !strings.HasPrefix(text, "// @") && !strings.HasPrefix(text, "// nolint:") && !strings.HasPrefix(text, "//nolint:") {
assert.Fail(t, "Go function comment must be placed before swagger comments")
return
}
@@ -369,7 +369,8 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) {
comment.router == "/api/v2/users/login" ||
comment.router == "/api/v2/users/otp/request" ||
comment.router == "/api/v2/users/otp/change-password" ||
comment.router == "/api/v2/init-script/{os}/{arch}" {
comment.router == "/api/v2/init-script/{os}/{arch}" ||
comment.router == "/api/v2/chats/files/{file}/download" {
return // endpoints do not require authorization
}
if comment.router == "/api/v2/ai-gateway/serve" {
@@ -428,8 +429,10 @@ func assertProduce(t *testing.T, comment SwaggerComment) {
(comment.router == "/api/v2/workspaces/{workspace}/acl" && comment.method == "patch") ||
(comment.router == "/api/v2/init-script/{os}/{arch}" && comment.method == "get") ||
(comment.router == "/api/v2/organizations/{organization}/ai/spend/export" && comment.method == "get") ||
(comment.router == "/api/v2/templatebuilder/compose" && comment.method == "post") {
return // Exception: HTTP 200 is returned without response entity
(comment.router == "/api/v2/templatebuilder/compose" && comment.method == "post") ||
(comment.router == "/api/v2/chats/files/{file}" && comment.method == "get") ||
(comment.router == "/api/v2/chats/files/{file}/download" && comment.method == "get") {
return // Exception: HTTP 200 is returned without a response model
}
assert.Truef(t, comment.produce == "", "Response model is undefined, so we can't predict the content type: %v", comment)