feat!(coderd): remove the experimental chat API mounts after the transition window

This commit is contained in:
Michael Suchacz
2026-09-17 18:04:09 +00:00
parent 26e4429362
commit b008976a4d
19 changed files with 134 additions and 503 deletions
+3 -3
View File
@@ -320,20 +320,20 @@ func TestIsReplicaRelayRequest(t *testing.T) {
t.Run("WithHeader", func(t *testing.T) {
t.Parallel()
r, _ := http.NewRequestWithContext(context.Background(), "GET", "/api/experimental/chats/abc/stream", nil)
r, _ := http.NewRequestWithContext(context.Background(), "GET", "/api/v2/chats/abc/stream", nil)
r.Header.Set("X-Coder-Relay-Source-Replica", "some-uuid")
require.True(t, isReplicaRelayRequest(r))
})
t.Run("WithoutHeader", func(t *testing.T) {
t.Parallel()
r, _ := http.NewRequestWithContext(context.Background(), "GET", "/api/experimental/chats/abc/stream", nil)
r, _ := http.NewRequestWithContext(context.Background(), "GET", "/api/v2/chats/abc/stream", nil)
require.False(t, isReplicaRelayRequest(r))
})
t.Run("EmptyHeader", func(t *testing.T) {
t.Parallel()
r, _ := http.NewRequestWithContext(context.Background(), "GET", "/api/experimental/chats/abc/stream", nil)
r, _ := http.NewRequestWithContext(context.Background(), "GET", "/api/v2/chats/abc/stream", nil)
r.Header.Set("X-Coder-Relay-Source-Replica", "")
require.False(t, isReplicaRelayRequest(r))
})
+41 -112
View File
@@ -12,91 +12,31 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// chatFilesRateLimitMW returns the middleware enforcing FilesRateLimit
// on chat file routes. Both API prefixes mount the same instance, and
// the limiter keys on a prefix-stripped endpoint, so alternating
// prefixes cannot double the budget.
func (api *API) chatFilesRateLimitMW() func(http.Handler) http.Handler {
api.chatFilesRateLimitOnce.Do(func() {
api.chatFilesRateLimit = httpmw.RateLimitByAPICompatibilityEndpoint(api.FilesRateLimit, time.Minute)
})
return api.chatFilesRateLimit
}
// chatAPIPrefix identifies which API prefix a chat route mount serves.
type chatAPIPrefix int
const (
chatAPIPrefixV2 chatAPIPrefix = iota
chatAPIPrefixExperimental
)
// registerChatAPIRoutes mounts the chat API surface on r, the root router
// of an API prefix. /api/v2 and /api/experimental serve the same promoted
// routes during the CODAGT-921 compatibility window. The experimental
// mount also serves the routes that were not promoted, while /api/v2
// reserves their path segments so they return 404 instead of falling into
// the {chat} wildcard.
// TODO(CODAGT-921): unmount from /api/experimental after the transition
// window (tracked in CODAGT-922).
func (api *API) registerChatAPIRoutes(r chi.Router, apiKeyMiddleware func(http.Handler) http.Handler, prefix chatAPIPrefix) {
experimental := prefix == chatAPIPrefixExperimental
// registerChatAPIRoutes mounts the chat API surface on r, the /api/v2 root
// router.
func (api *API) registerChatAPIRoutes(r chi.Router, apiKeyMiddleware func(http.Handler) http.Handler) {
// Signed URL tokens authenticate downloads, so the route stays
// outside the API key middleware.
r.Group(func(r chi.Router) {
r.Use(api.chatFilesRateLimitMW())
r.Use(httpmw.RateLimit(api.FilesRateLimit, time.Minute))
r.Get("/chats/files/{file}/download", api.downloadChatFile)
})
r.Route("/chats", func(r chi.Router) {
r.Use(apiKeyMiddleware)
if experimental {
// Reserve removed collection paths so they return 404 instead of
// falling into the {chat} wildcard and failing UUID parsing.
for _, segment := range []string{"/models", "/model-configs"} {
r.Route(segment, func(r chi.Router) {
r.NotFound(func(rw http.ResponseWriter, _ *http.Request) {
httpapi.RouteNotFound(rw)
})
})
}
// TODO(cian): place under /api/experimental/chats/config
r.Route("/providers", func(r chi.Router) {
r.Get("/", api.listChatProviders)
r.Post("/", api.createChatProvider)
r.Route("/{providerConfig}", func(r chi.Router) {
r.Patch("/", api.updateChatProvider)
r.Delete("/", api.deleteChatProvider)
})
// Reserve the unmounted segment so it returns 404 instead of
// falling into the {chat} wildcard and failing UUID parsing
// with a 400.
r.Route("/model-configs", func(r chi.Router) {
r.NotFound(func(rw http.ResponseWriter, _ *http.Request) {
httpapi.RouteNotFound(rw)
})
r.Route("/user-provider-configs", func(r chi.Router) {
r.Get("/", api.listUserChatProviderConfigs)
r.Route("/{providerConfig}", func(r chi.Router) {
r.Put("/", api.upsertUserChatProviderKey)
r.Delete("/", api.deleteUserChatProviderKey)
})
})
} else {
// Reserve unmounted segments so they return 404 instead of
// falling into the {chat} wildcard and failing UUID parsing
// with a 400.
segments := []string{"/model-configs"}
// TODO(CODAGT-922): drop the provider reservations with the
// experimental mounts.
segments = append(segments, "/providers", "/user-provider-configs")
for _, segment := range segments {
r.Route(segment, func(r chi.Router) {
r.NotFound(func(rw http.ResponseWriter, _ *http.Request) {
httpapi.RouteNotFound(rw)
})
})
}
}
})
r.Get("/by-workspace", api.chatsByWorkspace)
r.Get("/", api.listChats)
r.Post("/", api.postChats)
r.Get("/watch", api.watchChats)
r.Route("/files", func(r chi.Router) {
r.Use(api.chatFilesRateLimitMW())
r.Use(httpmw.RateLimit(api.FilesRateLimit, time.Minute))
r.Post("/", api.postChatFile)
r.Post("/{file}/download-url", api.postChatFileDownloadURL)
r.Get("/{file}", api.chatFileByID)
@@ -125,18 +65,6 @@ func (api *API) registerChatAPIRoutes(r chi.Router, apiKeyMiddleware func(http.H
r.Put("/debug-retention-days", api.putChatDebugRetentionDays)
r.Get("/auto-archive-days", api.getChatAutoArchiveDays)
r.Put("/auto-archive-days", api.putChatAutoArchiveDays)
if experimental {
r.Group(func(r chi.Router) {
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatVirtualDesktop))
r.Get("/computer-use-provider", api.getChatComputerUseProvider)
r.Put("/computer-use-provider", api.putChatComputerUseProvider)
})
r.Group(func(r chi.Router) {
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatAdvisor))
r.Get("/advisor", api.getChatAdvisorConfig)
r.Put("/advisor", api.putChatAdvisorConfig)
})
}
})
r.Route("/{chat}", func(r chi.Router) {
r.Use(httpmw.ExtractChatParam(api.Database))
@@ -167,34 +95,37 @@ func (api *API) registerChatAPIRoutes(r chi.Router, apiKeyMiddleware func(http.H
r.Get("/", api.streamChat)
r.Get("/parts", api.streamChatParts)
r.Get("/git", api.watchChatGit)
if experimental {
r.Get("/desktop", api.watchChatDesktop)
}
})
if experimental {
r.Route("/debug", func(r chi.Router) {
r.Get("/runs", api.getChatDebugRuns)
r.Get("/runs/{debugRun}", api.getChatDebugRun)
})
}
})
})
}
// registerMCPServerOAuth2Routes mounts the user-scoped MCP server OAuth2
// routes shared by both API prefixes.
func (api *API) registerMCPServerOAuth2Routes(r chi.Router, prefix chatAPIPrefix) {
if prefix == chatAPIPrefixExperimental {
// Providers pin the redirect URI when a session is established,
// so the callback URL cannot change for existing sessions without
// breaking token refresh and forcing a re-auth.
// TODO(CODAGT-922): promote once existing sessions can be
// re-authenticated against a /api/v2 callback.
r.Get("/servers/{mcpServer}/oauth2/callback", api.mcpServerOAuth2Callback)
}
// Disconnect stays outside organization routes so former organization
// members can delete their stored token after losing config read access.
r.Delete("/servers/{mcpServer}/oauth2/disconnect", api.mcpServerOAuth2Disconnect)
// registerExperimentalChatRoutes mounts the chat routes that were not
// promoted to /api/v2 on r, the /api/experimental root router.
func (api *API) registerExperimentalChatRoutes(r chi.Router, apiKeyMiddleware func(http.Handler) http.Handler) {
r.Route("/chats", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Route("/config", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatVirtualDesktop))
r.Get("/computer-use-provider", api.getChatComputerUseProvider)
r.Put("/computer-use-provider", api.putChatComputerUseProvider)
})
r.Group(func(r chi.Router) {
r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatAdvisor))
r.Get("/advisor", api.getChatAdvisorConfig)
r.Put("/advisor", api.putChatAdvisorConfig)
})
})
r.Route("/{chat}", func(r chi.Router) {
r.Use(httpmw.ExtractChatParam(api.Database))
r.Get("/stream/desktop", api.watchChatDesktop)
r.Route("/debug", func(r chi.Router) {
r.Get("/runs", api.getChatDebugRuns)
r.Get("/runs/{debugRun}", api.getChatDebugRun)
})
})
})
}
func (api *API) registerUserAIProviderKeyRoutes(r chi.Router) {
@@ -208,7 +139,7 @@ func (api *API) registerUserAIProviderKeyRoutes(r chi.Router) {
// registerOrganizationChatRoutes mounts the organization-scoped chat and
// MCP server configuration routes; r must already extract the
// organization parameter.
func (api *API) registerOrganizationChatRoutes(r chi.Router, prefix chatAPIPrefix) {
func (api *API) registerOrganizationChatRoutes(r chi.Router) {
r.Route("/mcp-servers", func(r chi.Router) {
r.Get("/", api.listMCPServerConfigs)
r.Post("/", api.createMCPServerConfig)
@@ -223,10 +154,8 @@ func (api *API) registerOrganizationChatRoutes(r chi.Router, prefix chatAPIPrefi
policy.ActionShare)).Get("/acl", api.mcpServerConfigACL)
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
policy.ActionShare)).Patch("/acl", api.patchMCPServerConfigACL)
if prefix == chatAPIPrefixV2 {
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
policy.ActionShare)).Get("/acl/available", api.mcpServerConfigACLAvailable)
}
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
policy.ActionShare)).Get("/acl/available", api.mcpServerConfigACLAvailable)
r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize,
policy.ActionRead)).Get("/oauth2/connect", api.mcpServerOAuth2Connect)
})
-36
View File
@@ -1,36 +0,0 @@
package coderd
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestChatFilesRateLimitMWCompatibilityAliases(t *testing.T) {
t.Parallel()
api := &API{Options: &Options{FilesRateLimit: 1}}
handler := api.chatFilesRateLimitMW()(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
for i, requestPath := range []string{
"/api/v2/chats/files/00000000-0000-0000-0000-000000000000",
"/api/experimental/chats/files/00000000-0000-0000-0000-000000000000",
} {
req := httptest.NewRequest(http.MethodGet, requestPath, nil)
req.RemoteAddr = "192.0.2.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
resp := rec.Result()
_ = resp.Body.Close()
expectedStatus := http.StatusOK
if i > 0 {
expectedStatus = http.StatusTooManyRequests
}
require.Equal(t, expectedStatus, resp.StatusCode, requestPath)
}
}
+11 -9
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
@@ -13,7 +14,7 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestChatRoutesCompatibility(t *testing.T) {
func TestChatRouteMounts(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -29,10 +30,9 @@ func TestChatRoutesCompatibility(t *testing.T) {
})
for _, route := range []string{
"/api/experimental/chats",
"/api/experimental/chats/config/system-prompt",
"/api/v2/chats",
"/api/v2/chats/config/system-prompt",
fmt.Sprintf("/api/experimental/chats/%s/debug/runs", chat.ID),
} {
res, err := client.Request(ctx, http.MethodGet, route, nil)
require.NoError(t, err)
@@ -44,18 +44,20 @@ func TestChatRoutesCompatibility(t *testing.T) {
method string
path string
}{
{http.MethodGet, "/api/experimental/chats/models"},
{http.MethodGet, "/api/experimental/chats/model-configs"},
{http.MethodPost, "/api/experimental/chats/model-configs"},
// Promoted routes no longer answer on /api/experimental.
{http.MethodGet, "/api/experimental/chats"},
{http.MethodGet, "/api/experimental/chats/config/system-prompt"},
{http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/chats/models", firstUser.OrganizationID)},
{http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers", firstUser.OrganizationID)},
{http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/disconnect", uuid.New())},
{http.MethodGet, "/api/experimental/users/me/ai-provider-keys"},
// Never-promoted routes are absent from /api/v2.
{http.MethodGet, "/api/v2/chats/model-configs"},
{http.MethodPost, "/api/v2/chats/model-configs"},
{http.MethodGet, "/api/v2/chats/providers"},
{http.MethodGet, "/api/v2/chats/user-provider-configs"},
{http.MethodGet, "/api/v2/chats/config/computer-use-provider"},
{http.MethodGet, "/api/v2/chats/config/advisor"},
{http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/debug/runs", chat.ID)},
{http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/stream/desktop", chat.ID)},
{http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/not-a-uuid/acl/available", firstUser.OrganizationID)},
{http.MethodGet, "/api/v2/mcp/servers/not-a-uuid/oauth2/callback"},
{http.MethodPost, "/api/v2/mcp/http/server"},
} {
+10 -32
View File
@@ -1351,32 +1351,14 @@ func New(options *Options) *API {
r.Delete("/", api.deleteUserSkill)
})
})
// Chat routes are promoted to /api/v2. CODAGT-921 decided a compatibility
// window, so these experimental duplicates must remain for one release.
// TODO(CODAGT-921): remove after the transition window (tracked in CODAGT-922).
r.Route("/users/{user}/ai-provider-keys", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractUserParam(options.Database),
)
api.registerUserAIProviderKeyRoutes(r)
})
r.Route("/organizations", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Route("/{organization}", func(r chi.Router) {
r.Use(httpmw.ExtractOrganizationParam(options.Database))
api.registerOrganizationChatRoutes(r, chatAPIPrefixExperimental)
r.Route("/members/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractOrganizationMemberParam(options.Database))
api.registerOrganizationMemberChatRoutes(r)
})
})
})
api.registerChatAPIRoutes(r, apiKeyMiddleware, chatAPIPrefixExperimental)
api.registerExperimentalChatRoutes(r, apiKeyMiddleware)
r.Route("/mcp", func(r chi.Router) {
r.Use(apiKeyMiddleware)
api.registerMCPServerOAuth2Routes(r, chatAPIPrefixExperimental)
// Providers pin the redirect URI when a session is established,
// so the callback URL cannot change for existing sessions without
// breaking token refresh and forcing a re-auth.
r.Get("/servers/{mcpServer}/oauth2/callback", api.mcpServerOAuth2Callback)
// MCP HTTP transport endpoint with mandatory authentication.
r.Route("/http", func(r chi.Router) {
r.Use(
@@ -1432,10 +1414,12 @@ func New(options *Options) *API {
r.Get("/available", handleExperimentsAvailable)
r.Get("/", api.handleExperimentsGet)
})
api.registerChatAPIRoutes(r, apiKeyMiddleware, chatAPIPrefixV2)
api.registerChatAPIRoutes(r, apiKeyMiddleware)
r.Route("/mcp", func(r chi.Router) {
r.Use(apiKeyMiddleware)
api.registerMCPServerOAuth2Routes(r, chatAPIPrefixV2)
// Disconnect stays outside organization routes so former organization
// members can delete their stored token after losing config read access.
r.Delete("/servers/{mcpServer}/oauth2/disconnect", api.mcpServerOAuth2Disconnect)
})
r.Get("/updatecheck", api.updateCheck)
@@ -1500,7 +1484,7 @@ func New(options *Options) *API {
r.Use(
httpmw.ExtractOrganizationParam(options.Database),
)
api.registerOrganizationChatRoutes(r, chatAPIPrefixV2)
api.registerOrganizationChatRoutes(r)
r.Get("/", api.organization)
r.Post("/templateversions", api.postTemplateVersionsByOrganization)
r.Route("/templates", func(r chi.Router) {
@@ -2175,12 +2159,6 @@ type API struct {
ctx context.Context
cancel context.CancelFunc
// chatFilesRateLimit is shared by the /api/experimental and /api/v2
// chat file mounts so the compatibility window does not double the
// FilesRateLimit budget.
chatFilesRateLimitOnce sync.Once
chatFilesRateLimit func(http.Handler) http.Handler
// DeploymentID is loaded from the database on startup.
DeploymentID string
+2 -38
View File
@@ -5522,7 +5522,7 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
}
if req.DeprecatedModelConfigID != nil || req.DeprecatedReasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Advisor model settings moved to PUT /api/experimental/organizations/{organization}/chats/model-overrides/advisor.",
Message: "Advisor model settings moved to PUT /api/v2/organizations/{organization}/chats/model-overrides/advisor.",
})
return
}
@@ -6376,8 +6376,7 @@ func (api *API) postChatFileDownloadURL(rw http.ResponseWriter, r *http.Request)
return
}
// TODO(CODAGT-922): flip to /api/v2 when experimental mounts are removed.
downloadURL := api.AccessURL.JoinPath("api", "experimental", "chats", "files", fileID.String(), "download")
downloadURL := api.AccessURL.JoinPath("api", "v2", "chats", "files", fileID.String(), "download")
downloadURL.RawQuery = url.Values{"token": {token}}.Encode()
digest := sha256.Sum256(chatFile.Data)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatFileDownloadURLResponse{
@@ -6942,41 +6941,6 @@ func (api *API) configuredProviderFromAIProviderKeys(provider database.AIProvide
}
}
func writeLegacyChatProviderGone(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusGone, codersdk.Response{
Message: "Legacy chat provider APIs were removed. Use AI provider APIs instead.",
Detail: "See https://coder.com/docs/ai-coder/agents/models#providers for AI provider configuration.",
})
}
func (*API) listChatProviders(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) createChatProvider(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) updateChatProvider(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) deleteChatProvider(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) listUserChatProviderConfigs(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) upsertUserChatProviderKey(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) deleteUserChatProviderKey(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
// @Summary List AI models and provider descriptors in an organization
// @ID list-ai-models-and-provider-descriptors-in-an-organization
// @Security CoderSessionToken
+6 -6
View File
@@ -237,7 +237,7 @@ func TestDeleteChatQueuedMessageMissingReturns404(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/99999999", chat.ID),
nil,
)
require.NoError(t, err)
@@ -264,7 +264,7 @@ func TestDeleteChatQueuedMessageEmptyQueueReturnsConflict(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/99999999", chat.ID),
nil,
)
require.NoError(t, err)
@@ -299,7 +299,7 @@ func TestPromoteChatQueuedMessageMissingReturns404(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999/promote", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/99999999/promote", chat.ID),
nil,
)
require.NoError(t, err)
@@ -326,7 +326,7 @@ func TestPromoteChatQueuedMessageEmptyQueueReturnsConflict(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999/promote", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/99999999/promote", chat.ID),
nil,
)
require.NoError(t, err)
@@ -436,7 +436,7 @@ func TestSubmitToolResultsRequiresActionSucceeds(t *testing.T) {
require.True(t, foundToolResult, "tool result message must be visible in chat history")
}
// TestPatchChatArchiveChildRejected verifies that PATCH /api/experimental/chats/{child}
// TestPatchChatArchiveChildRejected verifies that PATCH /api/v2/chats/{child}
// with archived=true returns the root-only error regardless of the
// child's current archived value, and does not change archive state on
// any family member.
@@ -485,7 +485,7 @@ func TestPatchChatArchiveChildRejected(t *testing.T) {
}
}
// TestPatchChatUnarchiveChildRejected verifies that PATCH /api/experimental/chats/{child}
// TestPatchChatUnarchiveChildRejected verifies that PATCH /api/v2/chats/{child}
// with archived=false on an archived family is rejected with the
// root-only error and leaves every family member archived. The child
// already matches the requested value? No, the family is archived;
+1 -1
View File
@@ -78,7 +78,7 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) {
model := createAdditionalChatModel(t, client, "openai", "gpt-4.1")
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats", codersdk.CreateChatRequest{
res, err := client.Request(ctx, http.MethodPost, "/api/v2/chats", codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{{
+3 -3
View File
@@ -448,7 +448,7 @@ func TestChatModelACLSparseUpdate(t *testing.T) {
require.Equal(t, expectedGroupRoles, chatModelACLGroupRoles(modelACL))
path := fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models/%s/acl",
"/api/v2/organizations/%s/chats/models/%s/acl",
firstUser.OrganizationID,
model.ID,
)
@@ -596,7 +596,7 @@ func TestChatModelACLValidationIsAtomic(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
res, err := adminClient.Request(ctx, http.MethodPatch, fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models/%s/acl",
"/api/v2/organizations/%s/chats/models/%s/acl",
firstUser.OrganizationID,
model.ID,
), test.body)
@@ -783,7 +783,7 @@ func TestCreateChatModelRejectsACLKeys(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.Request(ctx, http.MethodPost, fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models",
"/api/v2/organizations/%s/chats/models",
firstUser.OrganizationID,
), map[string]any{test.key: test.value})
require.NoError(t, err)
+45 -45
View File
@@ -3102,7 +3102,7 @@ func TestWatchChats(t *testing.T) {
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
@@ -3136,7 +3136,7 @@ func TestWatchChats(t *testing.T) {
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
@@ -3228,7 +3228,7 @@ func TestWatchChats(t *testing.T) {
require.NoError(t, err)
// Open the watch WebSocket.
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
@@ -3300,7 +3300,7 @@ func TestWatchChats(t *testing.T) {
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
@@ -3353,7 +3353,7 @@ func TestWatchChats(t *testing.T) {
res, err := unauthenticatedClient.Request(
ctx,
http.MethodGet,
"/api/experimental/chats/watch",
"/api/v2/chats/watch",
nil,
)
require.NoError(t, err)
@@ -3715,7 +3715,7 @@ func TestGetChatModel(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
res, err := client.Request(ctx, tc.method, fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models/%s",
"/api/v2/organizations/%s/chats/models/%s",
wrongOrganization.ID,
modelConfig.ID,
), tc.body)
@@ -4989,7 +4989,7 @@ func TestUpdateChatModel(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
res, err := client.Request(ctx, http.MethodPatch, fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models/%s",
"/api/v2/organizations/%s/chats/models/%s",
modelConfig.OrganizationID,
modelConfig.ID,
), map[string]any{tc.key: tc.value})
@@ -5065,7 +5065,7 @@ func TestUpdateChatModel(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodPatch,
fmt.Sprintf("/api/experimental/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
fmt.Sprintf("/api/v2/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
codersdk.UpdateChatModelRequest{DisplayName: "ignored"},
)
require.NoError(t, err)
@@ -5223,7 +5223,7 @@ func TestDeleteChatModelConfig(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
fmt.Sprintf("/api/v2/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
nil,
)
require.NoError(t, err)
@@ -6459,7 +6459,7 @@ func TestPatchChat(t *testing.T) {
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
@@ -7872,7 +7872,7 @@ func TestWatchChatsStatusChangeCarriesUpdatedLastModelConfigID(t *testing.T) {
Title: "watch direct model switch",
})
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
@@ -7937,14 +7937,14 @@ func TestWatchChatsStatusChangeCarriesUpdatedLastModelConfigID(t *testing.T) {
})
require.NoError(t, err)
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
promoteRes, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedResp.QueuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedResp.QueuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -9333,7 +9333,7 @@ func TestPatchChatMessage(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodPatch,
fmt.Sprintf("/api/experimental/chats/%s/messages/not-an-int", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/messages/not-an-int", chat.ID),
codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{
{
@@ -9787,7 +9787,7 @@ func TestStreamChat(t *testing.T) {
res, err := unauthenticatedClient.Request(
ctx,
http.MethodGet,
fmt.Sprintf("/api/experimental/chats/%s/stream", uuid.New()),
fmt.Sprintf("/api/v2/chats/%s/stream", uuid.New()),
nil,
)
require.NoError(t, err)
@@ -11016,7 +11016,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11054,7 +11054,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
invalidRes, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/not-an-int", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/not-an-int", chat.ID),
nil,
)
require.NoError(t, err)
@@ -11097,7 +11097,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
promoteRes, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11161,7 +11161,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
promoteRes, err := memberClient.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11198,7 +11198,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
invalidRes, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/not-an-int/promote", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/not-an-int/promote", chat.ID),
nil,
)
require.NoError(t, err)
@@ -11237,7 +11237,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
promoteRes, err := memberClient.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11273,7 +11273,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
promoteRes, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11358,7 +11358,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
promoteRes, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11451,7 +11451,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
promoteRes, err := client.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -11695,7 +11695,7 @@ This arrived as octet-stream.
coderdtest.CreateFirstUser(t, client.Client)
data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/files", bytes.NewReader(data), func(r *http.Request) {
res, err := client.Request(ctx, http.MethodPost, "/api/v2/chats/files", bytes.NewReader(data), func(r *http.Request) {
r.Header.Set("Content-Type", "image/png")
})
@@ -11713,7 +11713,7 @@ This arrived as octet-stream.
coderdtest.CreateFirstUser(t, client.Client)
data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/files?organization=not-a-uuid", bytes.NewReader(data), func(r *http.Request) {
res, err := client.Request(ctx, http.MethodPost, "/api/v2/chats/files?organization=not-a-uuid", bytes.NewReader(data), func(r *http.Request) {
r.Header.Set("Content-Type", "image/png")
})
require.NoError(t, err)
@@ -11799,7 +11799,7 @@ func TestGetChatFile(t *testing.T) {
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil)
fmt.Sprintf("/api/v2/chats/files/%s", uploaded.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
@@ -11819,7 +11819,7 @@ func TestGetChatFile(t *testing.T) {
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil)
fmt.Sprintf("/api/v2/chats/files/%s", uploaded.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
@@ -11842,7 +11842,7 @@ func TestGetChatFile(t *testing.T) {
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil)
fmt.Sprintf("/api/v2/chats/files/%s", uploaded.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
@@ -11874,7 +11874,7 @@ func TestGetChatFile(t *testing.T) {
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/experimental/chats/files/%s", row.ID), nil)
fmt.Sprintf("/api/v2/chats/files/%s", row.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
@@ -11903,7 +11903,7 @@ func TestGetChatFile(t *testing.T) {
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil)
fmt.Sprintf("/api/v2/chats/files/%s", uploaded.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
@@ -11927,7 +11927,7 @@ func TestGetChatFile(t *testing.T) {
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil)
fmt.Sprintf("/api/v2/chats/files/%s", uploaded.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
@@ -11955,7 +11955,7 @@ func TestGetChatFile(t *testing.T) {
coderdtest.CreateFirstUser(t, client.Client)
res, err := client.Request(ctx, http.MethodGet,
"/api/experimental/chats/files/not-a-uuid", nil)
"/api/v2/chats/files/not-a-uuid", nil)
require.NoError(t, err)
defer res.Body.Close()
err = codersdk.ReadBodyAsError(res)
@@ -12049,7 +12049,7 @@ func TestChatFileDownloadURL(t *testing.T) {
UserID: firstUser.UserID,
})
require.NoError(t, err)
downloadURL := client.URL.JoinPath("api", "experimental", "chats", "files", uploaded.ID.String(), "download")
downloadURL := client.URL.JoinPath("api", "v2", "chats", "files", uploaded.ID.String(), "download")
query := downloadURL.Query()
query.Set("token", token)
downloadURL.RawQuery = query.Encode()
@@ -12095,7 +12095,7 @@ func TestChatFileDownloadURL(t *testing.T) {
require.NoError(t, err)
downloadURL, err := url.Parse(download.URL)
require.NoError(t, err)
downloadURL.Path = fmt.Sprintf("/api/experimental/chats/files/%s/download", fileB.ID)
downloadURL.Path = fmt.Sprintf("/api/v2/chats/files/%s/download", fileB.ID)
res := get(t, ctx, downloadURL.String())
defer res.Body.Close()
@@ -12720,7 +12720,7 @@ func TestWatchChatGitAuthz(t *testing.T) {
res, err := adminClient.Request(
ctx,
http.MethodGet,
fmt.Sprintf("/api/experimental/chats/%s/stream/git", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/stream/git", chat.ID),
nil,
)
require.NoError(t, err)
@@ -14466,7 +14466,7 @@ func TestChatModelOverrides(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
res, err := adminClient.Request(ctx, http.MethodGet, "/api/experimental/chats/config/model-override/general", nil)
res, err := adminClient.Request(ctx, http.MethodGet, "/api/v2/chats/config/model-override/general", nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
@@ -14742,7 +14742,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) {
})
t.Run("LegacyRouteRemoved", func(t *testing.T) {
res, err := memberClient.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-personal-model-overrides", nil)
res, err := memberClient.Request(ctx, http.MethodGet, "/api/v2/chats/config/user-personal-model-overrides", nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
@@ -15626,7 +15626,7 @@ func TestChatAdvisorConfig_StaleModelWriteRejected(t *testing.T) {
} {
err := adminClient.UpdateChatAdvisorConfig(ctx, req)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Advisor model settings moved to PUT /api/experimental/organizations/{organization}/chats/model-overrides/advisor.", sdkErr.Message)
require.Equal(t, "Advisor model settings moved to PUT /api/v2/organizations/{organization}/chats/model-overrides/advisor.", sdkErr.Message)
}
}
@@ -16647,7 +16647,7 @@ func TestSubmitToolResults(t *testing.T) {
// request lets the invalid payload reach the server so we
// can verify server-side validation.
rawBody := `{"results":[{"tool_call_id":"call_json","output":not-json,"is_error":false}]}`
url := client.URL.JoinPath(fmt.Sprintf("/api/experimental/chats/%s/tool-results", chat.ID)).String()
url := client.URL.JoinPath(fmt.Sprintf("/api/v2/chats/%s/tool-results", chat.ID)).String()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(rawBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
@@ -17155,7 +17155,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodGet,
fmt.Sprintf("/api/experimental/chats/%s/messages?after_id=-1", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/messages?after_id=-1", chat.ID),
nil,
)
require.NoError(t, err)
@@ -17186,7 +17186,7 @@ func TestGetChatMessages_Pagination(t *testing.T) {
res, err := client.Request(
ctx,
http.MethodGet,
fmt.Sprintf("/api/experimental/chats/%s/messages?after_id=abc", chat.ID),
fmt.Sprintf("/api/v2/chats/%s/messages?after_id=abc", chat.ID),
nil,
)
require.NoError(t, err)
@@ -17461,7 +17461,7 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
res, err := sharedClient.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -17496,7 +17496,7 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
res, err := sharedClient.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
@@ -17650,7 +17650,7 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) {
promoteRes, err := adminClient.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
fmt.Sprintf("/api/v2/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
+1 -28
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"path"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -23,16 +22,6 @@ import (
// RateLimit returns a handler that limits requests per-minute based
// on IP, endpoint, and user ID (if available).
func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler {
return rateLimitWithEndpointKey(count, window, keyByNormalizedEndpoint)
}
// RateLimitByAPICompatibilityEndpoint shares rate-limit buckets for matching
// endpoints under the /api/v2 and /api/experimental compatibility prefixes.
func RateLimitByAPICompatibilityEndpoint(count int, window time.Duration) func(http.Handler) http.Handler {
return rateLimitWithEndpointKey(count, window, keyByAPICompatibilityEndpoint)
}
func rateLimitWithEndpointKey(count int, window time.Duration, endpointKey func(*http.Request) (string, error)) func(http.Handler) http.Handler {
// -1 is no rate limit
if count <= 0 {
return func(handler http.Handler) http.Handler {
@@ -97,7 +86,7 @@ func rateLimitWithEndpointKey(count int, window time.Duration, endpointKey func(
"%q provided but user is not %v",
codersdk.BypassRatelimitHeader, rbac.RoleOwner(),
)
}, endpointKey),
}, keyByNormalizedEndpoint),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusTooManyRequests, codersdk.Response{
Message: fmt.Sprintf("You've been rate limited for sending more than %v requests in %v.", count, window),
@@ -121,22 +110,6 @@ func keyByNormalizedEndpoint(r *http.Request) (string, error) {
return path.Clean(p), nil
}
func keyByAPICompatibilityEndpoint(r *http.Request) (string, error) {
p, err := keyByNormalizedEndpoint(r)
if err != nil {
return "", err
}
for _, prefix := range []string{"/api/v2", "/api/experimental"} {
if p == prefix {
return "/", nil
}
if strings.HasPrefix(p, prefix+"/") {
return strings.TrimPrefix(p, prefix), nil
}
}
return p, nil
}
// RateLimitByAuthToken returns a handler that limits requests based on the
// authentication token in the request.
//
-24
View File
@@ -201,30 +201,6 @@ func TestRateLimit(t *testing.T) {
})
}
func TestRateLimitByAPICompatibilityEndpoint(t *testing.T) {
t.Parallel()
rtr := chi.NewRouter()
rtr.Use(httpmw.RateLimitByAPICompatibilityEndpoint(1, time.Second))
rtr.Get("/*", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})
remoteAddr := randRemoteAddr()
for i, p := range []string{
"/api/v2/chats/files/00000000-0000-0000-0000-000000000000",
"/api/experimental/chats/files/00000000-0000-0000-0000-000000000000",
} {
req := httptest.NewRequest("GET", p, nil)
req.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
rtr.ServeHTTP(rec, req)
resp := rec.Result()
_ = resp.Body.Close()
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests, p)
}
}
func TestRateLimitByAuthToken(t *testing.T) {
t.Parallel()
-2
View File
@@ -1567,8 +1567,6 @@ func (api *API) markMCPTokenRefreshFailure(
// external authorization servers, so it must not change when other MCP
// routes move. The route registration in coderd.go and the OAuth cookie
// Path values must stay aligned with it.
// TODO(CODAGT-922): define a migration story before moving registered
// redirect URIs to /api/v2.
func mcpServerOAuth2CallbackPath(configID uuid.UUID) string {
return fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", configID)
}
+1 -1
View File
@@ -575,7 +575,7 @@ func Test_Middleware(t *testing.T) {
)
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
r := httptest.NewRequest("GET", "/api/experimental/chats/files/abc/download?token="+token, nil)
r := httptest.NewRequest("GET", "/api/v2/chats/files/abc/download?token="+token, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
+2
View File
@@ -427,6 +427,8 @@ This section maps the public endpoints that mutate chat state to the transitions
Chat routes are registered once by `registerChatAPIRoutes` and mounted under both `/api/experimental` and `/api/v2` during a compatibility window. Paths in this document are written with one prefix or the other, but every promoted route answers on both. The routes that were not promoted answer only on `/api/experimental`; at the time of writing these are the `providers` and `user-provider-configs` collections under `/chats`, the `computer-use-provider` and `advisor` routes under `/chats/config`, `GET /chats/{chat}/stream/desktop`, and `GET /chats/{chat}/debug/runs` with `GET /chats/{chat}/debug/runs/{debugRun}`. Each mount reserves the top-level `/chats/<segment>` collection paths it does not serve (`model-configs`, `providers`, and `user-provider-configs` on `/api/v2`; `models` and `model-configs` on `/api/experimental`) so they return 404 instead of matching the `{chat}` wildcard and failing UUID parsing.
TODO: CODAGT-922 removed the `/api/experimental` duplicates, the tombstone `providers` and `user-provider-configs` routes, and the reservations on the experimental mount; promoted routes now answer only on `/api/v2`. Update the paragraph above and the `/api/experimental` route headings in this section.
### Organization-scoped model discovery
Clients discover models through `GET /api/v2/organizations/{organization}/chats/models`. The handler requires either full API token scope or chat model configuration read scope, then queries only configs in the requested organization that pass the caller's RBAC filter.
-124
View File
@@ -1227,31 +1227,6 @@ type ChatProviderConfig struct {
UpdatedAt time.Time `json:"updated_at,omitempty" format:"date-time"`
}
// CreateChatProviderConfigRequest creates a chat provider config.
type CreateChatProviderConfigRequest struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name,omitempty"`
Icon string `json:"icon,omitempty"`
APIKey string `json:"api_key,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
CentralAPIKeyEnabled *bool `json:"central_api_key_enabled,omitempty"`
AllowUserAPIKey *bool `json:"allow_user_api_key,omitempty"`
AllowCentralAPIKeyFallback *bool `json:"allow_central_api_key_fallback,omitempty"`
}
// UpdateChatProviderConfigRequest updates a chat provider config.
type UpdateChatProviderConfigRequest struct {
DisplayName string `json:"display_name,omitempty"`
Icon string `json:"icon,omitempty"`
APIKey *string `json:"api_key,omitempty"`
BaseURL *string `json:"base_url,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
CentralAPIKeyEnabled *bool `json:"central_api_key_enabled,omitempty"`
AllowUserAPIKey *bool `json:"allow_user_api_key,omitempty"`
AllowCentralAPIKeyFallback *bool `json:"allow_central_api_key_fallback,omitempty"`
}
// AIProviderSummary is provider metadata embedded in other API responses.
type AIProviderSummary struct {
ID uuid.UUID `json:"id" format:"uuid"`
@@ -2027,64 +2002,6 @@ func (c *Client) ListChats(ctx context.Context, opts *ListChatsOptions) ([]Chat,
return chats, ReadBodyAsJSON(res, &chats)
}
// ListChatProviders returns admin-managed chat provider configs.
func (c *ExperimentalClient) ListChatProviders(ctx context.Context) ([]ChatProviderConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/providers", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var providers []ChatProviderConfig
return providers, ReadBodyAsJSON(res, &providers)
}
// CreateChatProvider creates an admin-managed chat provider config.
func (c *ExperimentalClient) CreateChatProvider(ctx context.Context, req CreateChatProviderConfigRequest) (ChatProviderConfig, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats/providers", req)
if err != nil {
return ChatProviderConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return ChatProviderConfig{}, ReadBodyAsError(res)
}
var provider ChatProviderConfig
return provider, ReadBodyAsJSON(res, &provider)
}
// UpdateChatProvider updates an admin-managed chat provider config.
func (c *ExperimentalClient) UpdateChatProvider(ctx context.Context, providerID uuid.UUID, req UpdateChatProviderConfigRequest) (ChatProviderConfig, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/providers/%s", providerID), req)
if err != nil {
return ChatProviderConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatProviderConfig{}, ReadBodyAsError(res)
}
var provider ChatProviderConfig
return provider, ReadBodyAsJSON(res, &provider)
}
// DeleteChatProvider deletes an admin-managed chat provider config.
func (c *ExperimentalClient) DeleteChatProvider(ctx context.Context, providerID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/providers/%s", providerID), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// ListUserAIProviderKeyConfigs returns user-scoped AI provider key configs.
func (c *Client) ListUserAIProviderKeyConfigs(ctx context.Context, user string) ([]UserAIProviderKeyConfig, error) {
res, err := c.Request(ctx, http.MethodGet, userAIProviderKeysPath(user), nil)
@@ -2130,47 +2047,6 @@ func userAIProviderKeysPath(user string) string {
return fmt.Sprintf("/api/v2/users/%s/ai-provider-keys", url.PathEscape(user))
}
// ListUserChatProviderConfigs returns user-scoped chat provider configs.
func (c *ExperimentalClient) ListUserChatProviderConfigs(ctx context.Context) ([]UserChatProviderConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/user-provider-configs", nil)
if err != nil {
return nil, xerrors.Errorf("list user chat provider configs: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var configs []UserChatProviderConfig
return configs, ReadBodyAsJSON(res, &configs)
}
// UpsertUserChatProviderKey creates or replaces a user API key for a provider.
func (c *ExperimentalClient) UpsertUserChatProviderKey(ctx context.Context, providerID uuid.UUID, req CreateUserChatProviderKeyRequest) (UserChatProviderConfig, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/experimental/chats/user-provider-configs/%s", providerID), req)
if err != nil {
return UserChatProviderConfig{}, xerrors.Errorf("upsert user chat provider key: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserChatProviderConfig{}, ReadBodyAsError(res)
}
var config UserChatProviderConfig
return config, ReadBodyAsJSON(res, &config)
}
// DeleteUserChatProviderKey deletes a user API key for a provider.
func (c *ExperimentalClient) DeleteUserChatProviderKey(ctx context.Context, providerID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/user-provider-configs/%s", providerID), nil)
if err != nil {
return xerrors.Errorf("delete user chat provider key: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// ChatModels returns the chat model configs the caller can read in one
// organization, plus the redacted provider descriptors the authoring page
// needs, for org-scoped management and picker surfaces.
@@ -50,4 +50,4 @@ Failed advisor invocations refund the per-turn budget.
The same configuration is available through the API: runtime limits at
`GET`/`PUT` `/api/experimental/chats/config/advisor`, and the advisor
model override at
`PUT /api/experimental/organizations/{organization}/chats/model-overrides/advisor`.
`PUT /api/v2/organizations/{organization}/chats/model-overrides/advisor`.
+7 -7
View File
@@ -123,7 +123,7 @@ func TestMCPServerConfigUpdateOnlyRoleReachesACLExcludedConfigs(t *testing.T) {
require.Equal(t, disabled.URL, fetched.URL)
requireMCPServerConfigRequestStatus(t, updateOnly, http.MethodGet,
"/api/experimental/organizations/"+firstUser.OrganizationID.String()+"/mcp-servers/"+disabled.ID.String()+"/oauth2/connect",
"/api/v2/organizations/"+firstUser.OrganizationID.String()+"/mcp-servers/"+disabled.ID.String()+"/oauth2/connect",
nil, http.StatusNotFound)
updatedName := "updated-hidden-mcp"
@@ -187,7 +187,7 @@ func TestMCPServerConfigDeleteOnlyRoleReachesDisabled(t *testing.T) {
require.Empty(t, fetched.URL)
requireMCPServerConfigRequestStatus(t, deleteOnly, http.MethodGet,
"/api/experimental/organizations/"+firstUser.OrganizationID.String()+"/mcp-servers/"+disabled.ID.String()+"/oauth2/connect",
"/api/v2/organizations/"+firstUser.OrganizationID.String()+"/mcp-servers/"+disabled.ID.String()+"/oauth2/connect",
nil, http.StatusNotFound)
err = deleteOnly.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, disabled.ID)
@@ -270,7 +270,7 @@ func TestMCPServerConfigShareOnlyRoleRoutes(t *testing.T) {
err = shareOnly.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
require.NoError(t, err)
configPath := "/api/experimental/organizations/" + firstUser.OrganizationID.String() + "/mcp-servers/" + config.ID.String()
configPath := "/api/v2/organizations/" + firstUser.OrganizationID.String() + "/mcp-servers/" + config.ID.String()
requireMCPServerConfigRequestStatus(t, shareOnly, http.MethodGet, configPath, nil, http.StatusNotFound)
requireMCPServerConfigRequestStatus(t, shareOnly, http.MethodGet, configPath+"/oauth2/connect", nil, http.StatusNotFound)
requireMCPServerConfigRequestStatus(t, shareOnly, http.MethodPatch, configPath,
@@ -329,7 +329,7 @@ func TestMCPServerConfigReadOnlyRoleCanConnect(t *testing.T) {
}
requireMCPServerConfigRequestStatus(t, readOnly, http.MethodGet,
"/api/experimental/organizations/"+firstUser.OrganizationID.String()+"/mcp-servers/"+config.ID.String()+"/oauth2/connect",
"/api/v2/organizations/"+firstUser.OrganizationID.String()+"/mcp-servers/"+config.ID.String()+"/oauth2/connect",
nil, http.StatusTemporaryRedirect)
}
@@ -380,7 +380,7 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) {
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
otherClient, _ := coderdtest.CreateAnotherUser(t, client, secondOrg.ID)
config := createMCPServerConfigForOrganization(t, client, firstUser.OrganizationID, "private-org-one-mcp")
organizationPath := "/api/experimental/organizations/" + secondOrg.ID.String() + "/mcp-servers/" + config.ID.String()
organizationPath := "/api/v2/organizations/" + secondOrg.ID.String() + "/mcp-servers/" + config.ID.String()
frozenPath := "/api/experimental/mcp/servers/" + config.ID.String()
mAudit.ResetLogs()
@@ -399,7 +399,7 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) {
// Disconnect returns 200 for every caller without a token,
// including nonexistent config IDs, so the response does not
// reveal whether the config exists.
{name: "OAuthDisconnect", method: http.MethodDelete, path: frozenPath + "/oauth2/disconnect", wantStatus: http.StatusOK},
{name: "OAuthDisconnect", method: http.MethodDelete, path: "/api/v2/mcp/servers/" + config.ID.String() + "/oauth2/disconnect", wantStatus: http.StatusOK},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
@@ -426,7 +426,7 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
rawDisconnect := func(id uuid.UUID) (int, string) {
res, err := otherClient.Request(ctx, http.MethodDelete,
"/api/experimental/mcp/servers/"+id.String()+"/oauth2/disconnect", nil)
"/api/v2/mcp/servers/"+id.String()+"/oauth2/disconnect", nil)
require.NoError(t, err)
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
-31
View File
@@ -3861,22 +3861,6 @@ export interface CreateChatModelRequest {
readonly model_config?: ChatModelCallConfig;
}
// From codersdk/chats.go
/**
* CreateChatProviderConfigRequest creates a chat provider config.
*/
export interface CreateChatProviderConfigRequest {
readonly provider: string;
readonly display_name?: string;
readonly icon?: string;
readonly api_key?: string;
readonly base_url?: string;
readonly enabled?: boolean;
readonly central_api_key_enabled?: boolean;
readonly allow_user_api_key?: boolean;
readonly allow_central_api_key_fallback?: boolean;
}
// From codersdk/chats.go
/**
* CreateChatRequest is the request to create a new chat.
@@ -9820,21 +9804,6 @@ export interface UpdateChatPlanModeInstructionsRequest {
readonly plan_mode_instructions: string;
}
// From codersdk/chats.go
/**
* UpdateChatProviderConfigRequest updates a chat provider config.
*/
export interface UpdateChatProviderConfigRequest {
readonly display_name?: string;
readonly icon?: string;
readonly api_key?: string;
readonly base_url?: string;
readonly enabled?: boolean;
readonly central_api_key_enabled?: boolean;
readonly allow_user_api_key?: boolean;
readonly allow_central_api_key_fallback?: boolean;
}
// From codersdk/chats.go
/**
* UpdateChatRequest is the request to update a chat.