From cc919306c30e44fcf69778525629e25146eabffa Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 19:27:10 +0000 Subject: [PATCH 1/9] feat(providers/kimicode): add native Responses API support --- docs/providers/kimicode.mdx | 10 +- internal/providers/kimicode/kimicode.go | 97 +++++++- internal/providers/kimicode/kimicode_test.go | 220 ++++++++++++++++++- 3 files changed, 315 insertions(+), 12 deletions(-) diff --git a/docs/providers/kimicode.mdx b/docs/providers/kimicode.mdx index 214ccff17..4b173f29c 100644 --- a/docs/providers/kimicode.mdx +++ b/docs/providers/kimicode.mdx @@ -7,8 +7,14 @@ keywords: ["Kimi Code", "Moonshot", "quota", "provider setup"] Kimi Code is an OpenAI-compatible coding assistant served at `https://api.kimi.com/coding/v1`. GoModel routes chat, model listing, embeddings, and passthrough requests through the shared -OpenAI adapter. The `/v1/responses` endpoint is translated through chat completions, while -files and batches are not supported by the upstream endpoint. +OpenAI adapter. The `/v1/responses` endpoint is forwarded natively to the upstream Responses +API instead of being translated through chat completions, while files and batches are not +supported by the upstream endpoint. + +Kimi Code retains no responses. Requests with `store: true` are rewritten to `store: false` +(the upstream rejects `store: true` with a 400), and requests carrying a +`previous_response_id` are rejected with an invalid-request error instead of being answered +statelessly, because the upstream can never resolve the referenced response. ## Configure diff --git a/internal/providers/kimicode/kimicode.go b/internal/providers/kimicode/kimicode.go index 2899e6acb..1d1167ea2 100644 --- a/internal/providers/kimicode/kimicode.go +++ b/internal/providers/kimicode/kimicode.go @@ -1,11 +1,18 @@ // Package kimicode provides Kimi Code API integration for the LLM gateway. // -// The "kimicode" provider routes to Kimi Code's OpenAI-compatible chat -// completions endpoint, so all transport goes through the shared chat-centric -// adapter and model IDs are forwarded unchanged. +// The "kimicode" provider routes to Kimi Code's OpenAI-compatible API: chat +// completions, model listing, embeddings, and passthrough go through the +// shared chat-centric adapter, while the Responses API is served natively by +// the upstream /responses endpoint. Kimi Code retains no responses, so +// previous_response_id is rejected with an invalid-request error and +// store=true is pinned to false. package kimicode import ( + "context" + "io" + "net/http" + "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/providers" "github.com/enterpilot/gomodel/internal/providers/openai" @@ -23,19 +30,93 @@ var Registration = providers.Registration{ } // Provider implements the core.Provider interface for Kimi Code. Kimi Code is -// OpenAI-compatible, so all transport goes through the shared chat-centric +// OpenAI-compatible, so most transport goes through the shared chat-centric // adapter: chat completions, model listing, embeddings, and passthrough are -// exposed via the embedded *openai.ChatCompatible. +// exposed via the embedded *openai.ChatCompatible. The Responses API is +// forwarded natively to the upstream /responses endpoint (rejecting +// previous_response_id and pinning store to false — see Responses below). type Provider struct { *openai.ChatCompatible + responses *openai.CompatibleProvider } var _ core.Provider = (*Provider)(nil) // New creates a new Kimi Code provider. func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { - return &Provider{openai.NewChatCompatible(cfg.APIKey, opts, openai.CompatibleProviderConfig{ + baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL) + return &Provider{ + ChatCompatible: openai.NewChatCompatible(cfg.APIKey, opts, compatibleConfig(baseURL)), + responses: openai.NewCompatibleProvider(cfg.APIKey, opts, compatibleConfig(baseURL)), + } +} + +// compatibleConfig is the shared OpenAI-compatible configuration for both +// adapter instances. SetHeaders defaults to plain Bearer auth because +// NewCompatibleProvider (unlike NewChatCompatible) applies no default. +func compatibleConfig(baseURL string) openai.CompatibleProviderConfig { + return openai.CompatibleProviderConfig{ ProviderName: "kimicode", - BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), - })} + BaseURL: baseURL, + SetHeaders: func(req *http.Request, apiKey string) { + providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{AuthScheme: "Bearer "}) + }, + } +} + +// SetBaseURL overrides the upstream endpoint for both the chat-centric +// adapter and the native Responses adapter. +func (p *Provider) SetBaseURL(url string) { + p.ChatCompatible.SetBaseURL(url) + p.responses.SetBaseURL(url) +} + +// Responses serves the Responses API natively through the upstream /responses +// endpoint. Kimi Code retains no responses, so a non-empty +// previous_response_id is rejected with an invalid-request error before any +// upstream call; store=true is pinned to false by adaptResponsesRequest. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + if err := rejectPreviousResponseID(req); err != nil { + return nil, err + } + return p.responses.Responses(ctx, adaptResponsesRequest(req)) +} + +// StreamResponses forwards the request to the upstream /responses endpoint +// with stream enabled, returning its Responses SSE stream. Like Responses, it +// rejects a non-empty previous_response_id before any upstream call. +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + if err := rejectPreviousResponseID(req); err != nil { + return nil, err + } + return p.responses.StreamResponses(ctx, adaptResponsesRequest(req)) +} + +// rejectPreviousResponseID fails requests chaining from an earlier response: +// Kimi Code does not retain responses, so a previous response ID can never be +// resolved and answering statelessly would silently drop the conversation +// context the caller expects. +func rejectPreviousResponseID(req *core.ResponsesRequest) error { + if req == nil || req.PreviousResponseID == "" { + return nil + } + return core.NewInvalidRequestError( + "kimicode does not retain responses: previous_response_id is not supported", nil) +} + +// adaptResponsesRequest pins store to false: the service retains no +// responses, so store=true fails upstream with a 400 (Postel's law — adapt +// instead of failing). previous_response_id is not adapted here; +// rejectPreviousResponseID rejects it instead. +func adaptResponsesRequest(req *core.ResponsesRequest) *core.ResponsesRequest { + if req == nil { + return nil + } + if req.Store == nil || !*req.Store { + return req + } + cp := *req + disabled := false + cp.Store = &disabled + return &cp } diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index 54eecf1ce..c9585dae3 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -1,9 +1,16 @@ package kimicode import ( + "context" + "io" "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/llmclient" "github.com/enterpilot/gomodel/internal/providers" @@ -12,7 +19,8 @@ import ( // Kimi Code is a thin wrapper over the shared chat-centric adapter and // forwards embeddings upstream unchanged, so the shared contract covers its -// surface. +// surface. The Responses API is forwarded natively to the upstream /responses +// endpoint. func TestChatCompatibleContract(t *testing.T) { providertest.AssertChatCompatible(t, providertest.ChatCompatible{ Registration: Registration, @@ -23,6 +31,214 @@ func TestChatCompatibleContract(t *testing.T) { opts.HTTPClient = client return New(providers.ProviderConfig{APIKey: apiKey, BaseURL: baseURL}, opts) }, - Embeddings: true, + Embeddings: true, + NativeResponses: true, + }) +} + +func boolPtr(b bool) *bool { return &b } + +// newTestProvider builds a provider wired to the test server through the +// injected HTTP client, matching how the shared contract constructs it. +func newTestProvider(server *httptest.Server) core.Provider { + opts := providertest.Options(llmclient.Hooks{}) + opts.HTTPClient = server.Client() + return New(providers.ProviderConfig{APIKey: "kimi-key", BaseURL: server.URL}, opts) +} + +func TestAdaptResponsesRequest(t *testing.T) { + t.Run("nil passes through", func(t *testing.T) { + assert.Nil(t, adaptResponsesRequest(nil)) + }) + + t.Run("clean request is returned unchanged", func(t *testing.T) { + req := &core.ResponsesRequest{Model: "kimi-for-coding", Input: "hi"} + assert.Same(t, req, adaptResponsesRequest(req)) + }) + + t.Run("store true is pinned to false", func(t *testing.T) { + req := &core.ResponsesRequest{Model: "kimi-for-coding", Input: "hi", Store: boolPtr(true)} + got := adaptResponsesRequest(req) + require.NotSame(t, req, got, "adapted request should be a copy") + require.NotNil(t, got.Store) + assert.False(t, *got.Store) + // The caller's request must not be mutated. + assert.True(t, *req.Store, "original request Store was mutated") + }) + + t.Run("previous_response_id is preserved", func(t *testing.T) { + // adaptResponsesRequest does not touch PreviousResponseID; the + // Responses/StreamResponses methods reject it instead (tested below). + req := &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "hi", + PreviousResponseID: "resp_old", + Store: boolPtr(false), + } + got := adaptResponsesRequest(req) + assert.Equal(t, "resp_old", got.PreviousResponseID) + require.NotNil(t, got.Store) + assert.False(t, *got.Store, "explicit store=false should stay false") + }) +} + +// responsesGoldenBody mirrors a real non-streaming /responses reply from the +// Kimi Code upstream (recorded 2026-09-08, trimmed to the members GoModel +// consumes). The upstream reply keeps extra members (prompt_cache_key, +// safety_identifier, service_tier); unknown members are ignored on decode. +const responsesGoldenBody = `{ + "id": "resp_golden", + "object": "response", + "created_at": 1788866012, + "completed_at": 1788866014, + "status": "completed", + "output": [ + { + "type": "reasoning", + "id": "rs_golden", + "status": "completed", + "summary": [{"type": "summary_text", "text": "Simple request."}] + }, + { + "type": "message", + "id": "msg_golden", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "OK", "annotations": []}] + } + ], + "usage": { + "input_tokens": 88, + "input_tokens_details": {"cache_write_tokens": 12, "cached_tokens": 88}, + "output_tokens": 53, + "output_tokens_details": {"reasoning_tokens": 37}, + "total_tokens": 141 + }, + "store": false, + "model": "kimi-for-coding" +}` + +func TestResponses_NativeEndpoint(t *testing.T) { + server, capture := providertest.JSONServer(t, http.StatusOK, responsesGoldenBody) + + provider := newTestProvider(server) + + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", + Store: boolPtr(true), + }) + require.NoError(t, err) + + req := capture.Last(t) + assert.Equal(t, "/responses", req.Path) + assert.Equal(t, "Bearer kimi-key", req.Header.Get("Authorization")) + body := req.JSON(t) + // store=true is pinned to false before the request leaves. + assert.Equal(t, false, body["store"], "wire store") + assert.NotContains(t, body, "stream", "non-streaming request must not set stream on the wire") + + assert.Equal(t, "resp_golden", resp.ID) + assert.Equal(t, "kimi-for-coding", resp.Model) + require.Len(t, resp.Output, 2) + require.NotNil(t, resp.Usage) + assert.Equal(t, 141, resp.Usage.TotalTokens) +} + +func TestStreamResponses_NativeEndpoint(t *testing.T) { + // No trailing [DONE]: providers.EnsureResponsesDone must append it. + server, capture := providertest.SSEServer(t, strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_stream","object":"response","status":"in_progress","model":"kimi-for-coding"}}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_stream","object":"response","status":"completed","model":"kimi-for-coding"}}`, + ``, + }, "\n")) + + provider := newTestProvider(server) + + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", + Store: boolPtr(true), + }) + require.NoError(t, err) + defer func() { _ = stream.Close() }() + + body, err := io.ReadAll(stream) + require.NoError(t, err) + + req := capture.Last(t) + assert.Equal(t, "/responses", req.Path) + assert.Equal(t, true, req.JSON(t)["stream"], "wire stream") + assert.Contains(t, string(body), "event: response.completed") + assert.True(t, strings.HasSuffix(strings.TrimSpace(string(body)), "data: [DONE]"), + "stream should end with data: [DONE], got %q", string(body)) +} + +// Kimi Code retains no responses, so a request chaining from an earlier +// response must be rejected before any upstream call instead of being +// answered statelessly. +func TestResponses_RejectsPreviousResponseID(t *testing.T) { + server, capture := providertest.JSONServer(t, http.StatusOK, responsesGoldenBody) + + provider := newTestProvider(server) + + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", + PreviousResponseID: "resp_old", + }) + require.Error(t, err) + assert.Nil(t, resp) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr, "error type = %T, want *core.GatewayError", err) + assert.Equal(t, core.ErrorTypeInvalidRequest, gatewayErr.Type) + assert.Contains(t, gatewayErr.Error(), "previous_response_id") + assert.Equal(t, 0, capture.Count(), "rejected request must not reach the upstream") +} + +func TestStreamResponses_RejectsPreviousResponseID(t *testing.T) { + server, capture := providertest.SSEServer(t, "") + + provider := newTestProvider(server) + + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", + PreviousResponseID: "resp_old", + }) + require.Error(t, err) + assert.Nil(t, stream) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr, "error type = %T, want *core.GatewayError", err) + assert.Equal(t, core.ErrorTypeInvalidRequest, gatewayErr.Type) + assert.Contains(t, gatewayErr.Error(), "previous_response_id") + assert.Equal(t, 0, capture.Count(), "rejected request must not reach the upstream") +} + +// SetBaseURL must retarget both the chat-centric adapter and the native +// Responses adapter. +func TestSetBaseURL(t *testing.T) { + server, capture := providertest.JSONServer(t, http.StatusOK, responsesGoldenBody) + + p := New(providers.ProviderConfig{APIKey: "kimi-key"}, providertest.Options(llmclient.Hooks{})) + kp, ok := p.(*Provider) + require.True(t, ok) + require.Equal(t, defaultBaseURL, kp.GetBaseURL()) + + kp.SetBaseURL(server.URL) + + assert.Equal(t, server.URL, kp.GetBaseURL()) + + _, err := p.Responses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", }) + require.NoError(t, err) + require.Equal(t, 1, capture.Count()) + assert.Equal(t, "/responses", capture.Last(t).Path) } From 7e711e9e9a2bcf8a9c357e204f9c3217d6104ff2 Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 19:45:16 +0000 Subject: [PATCH 2/9] test(providers/kimicode): cover whitespace continuation rejection Drop the transient STATUS.md from the changeset and add a regression test proving whitespace-only previous_response_id values are rejected locally before any upstream call (Greptile review round). --- internal/providers/kimicode/kimicode_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index c9585dae3..da522a343 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -198,6 +198,17 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { assert.Equal(t, core.ErrorTypeInvalidRequest, gatewayErr.Type) assert.Contains(t, gatewayErr.Error(), "previous_response_id") assert.Equal(t, 0, capture.Count(), "rejected request must not reach the upstream") + + t.Run("whitespace-only ID is rejected too", func(t *testing.T) { + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", + PreviousResponseID: " ", + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Equal(t, 0, capture.Count(), "whitespace ID must not reach the upstream") + }) } func TestStreamResponses_RejectsPreviousResponseID(t *testing.T) { From 7681462552a789b6053691b592cca17a035b920c Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 20:44:07 +0000 Subject: [PATCH 3/9] fix(providers/kimicode): reject conversation references locally Gateway-local Conversation IDs are meaningless to the stateless Kimi Code upstream; reject them with an invalid-request error before dispatch, like previous_response_id. Requests whose state the gateway already expanded pass through unchanged (CodeRabbit review). --- internal/providers/kimicode/kimicode.go | 19 ++++++++++++++----- internal/providers/kimicode/kimicode_test.go | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/providers/kimicode/kimicode.go b/internal/providers/kimicode/kimicode.go index 1d1167ea2..b702ad211 100644 --- a/internal/providers/kimicode/kimicode.go +++ b/internal/providers/kimicode/kimicode.go @@ -92,12 +92,21 @@ func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesReque return p.responses.StreamResponses(ctx, adaptResponsesRequest(req)) } -// rejectPreviousResponseID fails requests chaining from an earlier response: -// Kimi Code does not retain responses, so a previous response ID can never be -// resolved and answering statelessly would silently drop the conversation -// context the caller expects. +// rejectPreviousResponseID fails requests chaining from earlier state: +// Kimi Code does not retain responses, so neither a previous response ID nor +// a gateway-local conversation can be resolved upstream, and answering +// statelessly would silently drop the conversation context the caller +// expects. Requests whose state the gateway already expanded (both fields +// cleared) pass through. func rejectPreviousResponseID(req *core.ResponsesRequest) error { - if req == nil || req.PreviousResponseID == "" { + if req == nil { + return nil + } + if req.Conversation != nil { + return core.NewInvalidRequestError( + "kimicode does not retain responses: conversation is not supported", nil) + } + if req.PreviousResponseID == "" { return nil } return core.NewInvalidRequestError( diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index da522a343..e0ba3d1b2 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -209,6 +209,22 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { assert.Nil(t, resp) assert.Equal(t, 0, capture.Count(), "whitespace ID must not reach the upstream") }) + + t.Run("conversation reference is rejected", func(t *testing.T) { + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "Say OK", + Conversation: &core.ResponsesConversationRef{ID: "conv_old"}, + }) + require.Error(t, err) + assert.Nil(t, resp) + + var gatewayErr *core.GatewayError + require.ErrorAs(t, err, &gatewayErr) + assert.Equal(t, core.ErrorTypeInvalidRequest, gatewayErr.Type) + assert.Contains(t, gatewayErr.Error(), "conversation") + assert.Equal(t, 0, capture.Count(), "conversation request must not reach the upstream") + }) } func TestStreamResponses_RejectsPreviousResponseID(t *testing.T) { From c791b387b2bd1d457aada6ba26274d5175a61fde Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 20:57:48 +0000 Subject: [PATCH 4/9] test(providers/kimicode): cover nil request passthrough Direct rejectPreviousResponseID unit tests for the nil and clean-request branches; kimicode.go statement coverage back to 100% (codecov). --- internal/providers/kimicode/kimicode_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index e0ba3d1b2..63db12500 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -46,6 +46,16 @@ func newTestProvider(server *httptest.Server) core.Provider { return New(providers.ProviderConfig{APIKey: "kimi-key", BaseURL: server.URL}, opts) } +func TestRejectPreviousResponseID(t *testing.T) { + t.Run("nil request passes through", func(t *testing.T) { + assert.NoError(t, rejectPreviousResponseID(nil)) + }) + + t.Run("clean request passes through", func(t *testing.T) { + assert.NoError(t, rejectPreviousResponseID(&core.ResponsesRequest{Model: "kimi-for-coding", Input: "hi"})) + }) +} + func TestAdaptResponsesRequest(t *testing.T) { t.Run("nil passes through", func(t *testing.T) { assert.Nil(t, adaptResponsesRequest(nil)) From a5622278a6d10ef2b8ac480f529c6e8ffc4cd39a Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 23 Sep 2026 21:12:45 +0000 Subject: [PATCH 5/9] test(providers/kimicode): assert store pin on the stream path TestStreamResponses_NativeEndpoint sent Store: true but only asserted the stream flag; a regression dropping adaptResponsesRequest would have passed while sending unsupported store=true upstream (CodeRabbit review). --- internal/providers/kimicode/kimicode_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index 63db12500..374bc069b 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -181,7 +181,9 @@ func TestStreamResponses_NativeEndpoint(t *testing.T) { req := capture.Last(t) assert.Equal(t, "/responses", req.Path) - assert.Equal(t, true, req.JSON(t)["stream"], "wire stream") + wire := req.JSON(t) + assert.Equal(t, false, wire["store"], "wire store") + assert.Equal(t, true, wire["stream"], "wire stream") assert.Contains(t, string(body), "event: response.completed") assert.True(t, strings.HasSuffix(strings.TrimSpace(string(body)), "data: [DONE]"), "stream should end with data: [DONE], got %q", string(body)) From aaaac0212131baa95705673513a16659e2448c9c Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 30 Sep 2026 19:02:13 +0000 Subject: [PATCH 6/9] fix(providers/kimicode): address maintainer review on native Responses - Trim whitespace in rejectPreviousResponseID for parity with the gateway and the chat-translation validator; a whitespace-only ID is empty. - Serve native Responses through the single ChatCompatible adapter via the new Compatible() accessor instead of a second CompatibleProvider instance. - Cover the gateway-replayed chain: with a response store, a kimicode previous_response_id chain is expanded into input items (reasoning output included, ids stripped) and forwarded to /responses; add the gateway-level two-turn chain test and drop the round-trip tests the shared contract already covers. - Docs: chaining rejection applies only when no response/conversation store is configured; with a store the gateway replays the history. --- docs/providers/kimicode.mdx | 9 +- internal/providers/kimicode/kimicode.go | 61 ++++------ internal/providers/kimicode/kimicode_test.go | 111 +++++++++---------- internal/providers/openai/chat_compatible.go | 8 ++ internal/server/previous_response_test.go | 51 +++++++++ 5 files changed, 139 insertions(+), 101 deletions(-) diff --git a/docs/providers/kimicode.mdx b/docs/providers/kimicode.mdx index 4b173f29c..cb91f2358 100644 --- a/docs/providers/kimicode.mdx +++ b/docs/providers/kimicode.mdx @@ -12,9 +12,12 @@ API instead of being translated through chat completions, while files and batche supported by the upstream endpoint. Kimi Code retains no responses. Requests with `store: true` are rewritten to `store: false` -(the upstream rejects `store: true` with a 400), and requests carrying a -`previous_response_id` are rejected with an invalid-request error instead of being answered -statelessly, because the upstream can never resolve the referenced response. +(the upstream rejects `store: true` with a 400). Chaining works only through GoModel: with a +response store configured, the gateway expands a `previous_response_id` chain by replaying the +stored history into the request before dispatch, and a `conversation` reference resolves +through the conversation store the same way. Without those stores, a request carrying +`previous_response_id` or `conversation` is rejected with an invalid-request error, because +the upstream can never resolve the referenced state. ## Configure diff --git a/internal/providers/kimicode/kimicode.go b/internal/providers/kimicode/kimicode.go index b702ad211..1bbfca26a 100644 --- a/internal/providers/kimicode/kimicode.go +++ b/internal/providers/kimicode/kimicode.go @@ -4,14 +4,13 @@ // completions, model listing, embeddings, and passthrough go through the // shared chat-centric adapter, while the Responses API is served natively by // the upstream /responses endpoint. Kimi Code retains no responses, so -// previous_response_id is rejected with an invalid-request error and // store=true is pinned to false. package kimicode import ( "context" "io" - "net/http" + "strings" "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/providers" @@ -33,53 +32,32 @@ var Registration = providers.Registration{ // OpenAI-compatible, so most transport goes through the shared chat-centric // adapter: chat completions, model listing, embeddings, and passthrough are // exposed via the embedded *openai.ChatCompatible. The Responses API is -// forwarded natively to the upstream /responses endpoint (rejecting -// previous_response_id and pinning store to false — see Responses below). +// forwarded natively to the upstream /responses endpoint through the same +// adapter instance. type Provider struct { *openai.ChatCompatible - responses *openai.CompatibleProvider } var _ core.Provider = (*Provider)(nil) // New creates a new Kimi Code provider. func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { - baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL) - return &Provider{ - ChatCompatible: openai.NewChatCompatible(cfg.APIKey, opts, compatibleConfig(baseURL)), - responses: openai.NewCompatibleProvider(cfg.APIKey, opts, compatibleConfig(baseURL)), - } -} - -// compatibleConfig is the shared OpenAI-compatible configuration for both -// adapter instances. SetHeaders defaults to plain Bearer auth because -// NewCompatibleProvider (unlike NewChatCompatible) applies no default. -func compatibleConfig(baseURL string) openai.CompatibleProviderConfig { - return openai.CompatibleProviderConfig{ + return &Provider{openai.NewChatCompatible(cfg.APIKey, opts, openai.CompatibleProviderConfig{ ProviderName: "kimicode", - BaseURL: baseURL, - SetHeaders: func(req *http.Request, apiKey string) { - providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{AuthScheme: "Bearer "}) - }, - } -} - -// SetBaseURL overrides the upstream endpoint for both the chat-centric -// adapter and the native Responses adapter. -func (p *Provider) SetBaseURL(url string) { - p.ChatCompatible.SetBaseURL(url) - p.responses.SetBaseURL(url) + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + })} } // Responses serves the Responses API natively through the upstream /responses // endpoint. Kimi Code retains no responses, so a non-empty -// previous_response_id is rejected with an invalid-request error before any -// upstream call; store=true is pinned to false by adaptResponsesRequest. +// previous_response_id is rejected before any upstream call (see +// rejectPreviousResponseID); store=true is pinned to false by +// adaptResponsesRequest. func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { if err := rejectPreviousResponseID(req); err != nil { return nil, err } - return p.responses.Responses(ctx, adaptResponsesRequest(req)) + return p.Compatible().Responses(ctx, adaptResponsesRequest(req)) } // StreamResponses forwards the request to the upstream /responses endpoint @@ -89,15 +67,17 @@ func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesReque if err := rejectPreviousResponseID(req); err != nil { return nil, err } - return p.responses.StreamResponses(ctx, adaptResponsesRequest(req)) + return p.Compatible().StreamResponses(ctx, adaptResponsesRequest(req)) } // rejectPreviousResponseID fails requests chaining from earlier state: -// Kimi Code does not retain responses, so neither a previous response ID nor -// a gateway-local conversation can be resolved upstream, and answering -// statelessly would silently drop the conversation context the caller -// expects. Requests whose state the gateway already expanded (both fields -// cleared) pass through. +// Kimi Code cannot resolve a previous response ID or a gateway-local +// conversation upstream, and answering statelessly would silently drop the +// conversation context the caller expects. The rejection only fires when the +// gateway has no store to expand the chain with; requests whose state the +// gateway already replayed into input (both fields cleared) pass through. +// The ID check mirrors the gateway and the chat-translation validator, both +// of which treat a whitespace-only ID as empty. func rejectPreviousResponseID(req *core.ResponsesRequest) error { if req == nil { return nil @@ -106,7 +86,7 @@ func rejectPreviousResponseID(req *core.ResponsesRequest) error { return core.NewInvalidRequestError( "kimicode does not retain responses: conversation is not supported", nil) } - if req.PreviousResponseID == "" { + if strings.TrimSpace(req.PreviousResponseID) == "" { return nil } return core.NewInvalidRequestError( @@ -115,8 +95,7 @@ func rejectPreviousResponseID(req *core.ResponsesRequest) error { // adaptResponsesRequest pins store to false: the service retains no // responses, so store=true fails upstream with a 400 (Postel's law — adapt -// instead of failing). previous_response_id is not adapted here; -// rejectPreviousResponseID rejects it instead. +// instead of failing). func adaptResponsesRequest(req *core.ResponsesRequest) *core.ResponsesRequest { if req == nil { return nil diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index 374bc069b..3db7c011a 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -2,10 +2,8 @@ package kimicode import ( "context" - "io" "net/http" "net/http/httptest" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -128,70 +126,67 @@ const responsesGoldenBody = `{ "model": "kimi-for-coding" }` -func TestResponses_NativeEndpoint(t *testing.T) { +// TestResponses_ForwardsGatewayReplayedHistory covers what the gateway +// dispatches after expanding a previous_response_id chain against its +// response store: the stored history is replayed into input as items +// (reasoning and message items among them, IDs stripped) and +// previous_response_id is cleared. Kimi Code must forward that replayed +// input to /responses as stored instead of rejecting it. +func TestResponses_ForwardsGatewayReplayedHistory(t *testing.T) { server, capture := providertest.JSONServer(t, http.StatusOK, responsesGoldenBody) provider := newTestProvider(server) resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ Model: "kimi-for-coding", - Input: "Say OK", - Store: boolPtr(true), - }) - require.NoError(t, err) - - req := capture.Last(t) - assert.Equal(t, "/responses", req.Path) - assert.Equal(t, "Bearer kimi-key", req.Header.Get("Authorization")) - body := req.JSON(t) - // store=true is pinned to false before the request leaves. - assert.Equal(t, false, body["store"], "wire store") - assert.NotContains(t, body, "stream", "non-streaming request must not set stream on the wire") - - assert.Equal(t, "resp_golden", resp.ID) - assert.Equal(t, "kimi-for-coding", resp.Model) - require.Len(t, resp.Output, 2) - require.NotNil(t, resp.Usage) - assert.Equal(t, 141, resp.Usage.TotalTokens) -} - -func TestStreamResponses_NativeEndpoint(t *testing.T) { - // No trailing [DONE]: providers.EnsureResponsesDone must append it. - server, capture := providertest.SSEServer(t, strings.Join([]string{ - `event: response.created`, - `data: {"type":"response.created","response":{"id":"resp_stream","object":"response","status":"in_progress","model":"kimi-for-coding"}}`, - ``, - `event: response.completed`, - `data: {"type":"response.completed","response":{"id":"resp_stream","object":"response","status":"completed","model":"kimi-for-coding"}}`, - ``, - }, "\n")) - - provider := newTestProvider(server) - - stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ - Model: "kimi-for-coding", - Input: "Say OK", + Input: []any{ + map[string]any{ + "type": "message", + "role": "user", + "content": []any{map[string]any{"type": "input_text", "text": "remember: zebra"}}, + }, + map[string]any{ + "type": "reasoning", + "status": "completed", + "summary": []any{ + map[string]any{"type": "summary_text", "text": "thinking about zebras"}, + }, + }, + map[string]any{ + "type": "message", + "role": "assistant", + "status": "completed", + "content": []any{map[string]any{"type": "output_text", "text": "the word is zebra"}}, + }, + map[string]any{ + "type": "message", + "role": "user", + "content": []any{map[string]any{"type": "input_text", "text": "what is the word?"}}, + }, + }, Store: boolPtr(true), }) require.NoError(t, err) - defer func() { _ = stream.Close() }() - - body, err := io.ReadAll(stream) - require.NoError(t, err) + require.NotNil(t, resp) req := capture.Last(t) assert.Equal(t, "/responses", req.Path) wire := req.JSON(t) - assert.Equal(t, false, wire["store"], "wire store") - assert.Equal(t, true, wire["stream"], "wire stream") - assert.Contains(t, string(body), "event: response.completed") - assert.True(t, strings.HasSuffix(strings.TrimSpace(string(body)), "data: [DONE]"), - "stream should end with data: [DONE], got %q", string(body)) + assert.Equal(t, false, wire["store"], "store is pinned to false on the wire") + assert.NotContains(t, wire, "previous_response_id") + + items, ok := wire["input"].([]any) + require.True(t, ok, "wire input = %#v, want replayed items", wire["input"]) + require.Len(t, items, 4) + reasoning, ok := items[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, "reasoning", reasoning["type"], "replayed reasoning item must be forwarded unchanged") + assert.Equal(t, "user", items[3].(map[string]any)["role"], "the client's own turn is replayed last") } // Kimi Code retains no responses, so a request chaining from an earlier -// response must be rejected before any upstream call instead of being -// answered statelessly. +// response that the gateway could not expand (no store configured) must be +// rejected before any upstream call instead of being answered statelessly. func TestResponses_RejectsPreviousResponseID(t *testing.T) { server, capture := providertest.JSONServer(t, http.StatusOK, responsesGoldenBody) @@ -211,15 +206,17 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { assert.Contains(t, gatewayErr.Error(), "previous_response_id") assert.Equal(t, 0, capture.Count(), "rejected request must not reach the upstream") - t.Run("whitespace-only ID is rejected too", func(t *testing.T) { + t.Run("whitespace-only ID passes through", func(t *testing.T) { + // The gateway and the chat-translation validator treat a + // whitespace-only ID as empty; the provider must behave the same. resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ Model: "kimi-for-coding", Input: "Say OK", PreviousResponseID: " ", }) - require.Error(t, err) - assert.Nil(t, resp) - assert.Equal(t, 0, capture.Count(), "whitespace ID must not reach the upstream") + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, 1, capture.Count(), "whitespace-only ID is treated as empty") }) t.Run("conversation reference is rejected", func(t *testing.T) { @@ -235,7 +232,7 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { require.ErrorAs(t, err, &gatewayErr) assert.Equal(t, core.ErrorTypeInvalidRequest, gatewayErr.Type) assert.Contains(t, gatewayErr.Error(), "conversation") - assert.Equal(t, 0, capture.Count(), "conversation request must not reach the upstream") + assert.Equal(t, 1, capture.Count(), "conversation request must not reach the upstream") }) } @@ -259,8 +256,8 @@ func TestStreamResponses_RejectsPreviousResponseID(t *testing.T) { assert.Equal(t, 0, capture.Count(), "rejected request must not reach the upstream") } -// SetBaseURL must retarget both the chat-centric adapter and the native -// Responses adapter. +// SetBaseURL must retarget the single adapter serving both the chat-centric +// surface and the native Responses endpoint. func TestSetBaseURL(t *testing.T) { server, capture := providertest.JSONServer(t, http.StatusOK, responsesGoldenBody) diff --git a/internal/providers/openai/chat_compatible.go b/internal/providers/openai/chat_compatible.go index 437af6437..aaec07f09 100644 --- a/internal/providers/openai/chat_compatible.go +++ b/internal/providers/openai/chat_compatible.go @@ -47,6 +47,14 @@ func bearerHeaders(req *http.Request, apiKey string) { providers.SetAuthHeaders(req, apiKey, providers.AuthHeaderConfig{AuthScheme: "Bearer "}) } +// Compatible exposes the underlying OpenAI-compatible adapter, so a provider +// that embeds ChatCompatible can serve a native Responses endpoint through +// the same instance instead of building a second adapter with the same +// configuration. +func (c *ChatCompatible) Compatible() *CompatibleProvider { + return c.compatible +} + // SetBaseURL allows configuring a custom base URL for the provider. func (c *ChatCompatible) SetBaseURL(url string) { c.compatible.SetBaseURL(url) diff --git a/internal/server/previous_response_test.go b/internal/server/previous_response_test.go index e22376ae2..1296fa0e0 100644 --- a/internal/server/previous_response_test.go +++ b/internal/server/previous_response_test.go @@ -121,6 +121,57 @@ func TestResponsesWithPreviousResponseID_ChainCarriesFullHistory(t *testing.T) { require.Len(t, second.InputItems, 1) } +// TestResponsesWithPreviousResponseID_KimicodeChainReplaysHistory covers the +// native-Responses kimicode provider chaining through the gateway: kimicode +// has no Responses lifecycle, so the gateway treats it as translated and, +// with a response store, replays the stored chain into input instead of +// forwarding the id. The replayed items — reasoning output included, item +// ids stripped — are what reaches kimicode's /responses upstream. +func TestResponsesWithPreviousResponseID_KimicodeChainReplaysHistory(t *testing.T) { + provider := previousResponseTestProvider(t, "kimicode") + srv := New(provider, nil) + + // A turn the gateway served for kimicode earlier: the snapshot holds the + // client's input items and the provider's output, reasoning included. + err := srv.handler.currentResponseStore().Create(context.Background(), &responsestore.StoredResponse{ + Response: &core.ResponsesResponse{ + ID: "resp_kimi_1", Object: "response", Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "rs_1", Type: "reasoning", Status: "completed", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "summary": json.RawMessage(`[{"type":"summary_text","text":"thinking about zebras"}]`), + }), + }, + {ID: "msg_1", Type: "message", Role: "assistant", Content: []core.ResponsesContentItem{{Type: "output_text", Text: "the word is zebra"}}}, + }, + }, + InputItems: []json.RawMessage{json.RawMessage(`{"id":"in_1","type":"message","role":"user","content":[{"type":"input_text","text":"remember: zebra"}]}`)}, + Provider: "kimicode", + }) + require.NoError(t, err) + + rec := postResponses(t, srv, `{"model":"gpt-5-mini","input":"what is the word?","previous_response_id":"resp_kimi_1"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + forwarded := provider.capturedResponsesReq + require.NotNil(t, forwarded) + require.Empty(t, forwarded.PreviousResponseID, "translated providers get the id stripped before dispatch") + + items := forwardedInputItems(t, provider.capturingProvider) + require.Len(t, items, 4) + require.Equal(t, "reasoning", items[1]["type"], "stored reasoning output must replay unchanged: %#v", items[1]) + summary, _ := json.Marshal(items[1]["summary"]) + require.Contains(t, string(summary), "thinking about zebras") + for i, item := range items[:3] { + _, hasID := item["id"] + require.False(t, hasID, "stored item id must be stripped before dispatch (item %d): %#v", i, item) + } + text, _ := json.Marshal(items[2]["content"]) + require.Contains(t, string(text), "the word is zebra") + require.Equal(t, "user", items[3]["role"], "the client's own turn is replayed last") +} + func TestResponsesWithPreviousResponseID_StreamingChainedTurn(t *testing.T) { provider := previousResponseTestProvider(t, "anthropic") provider.streamData = "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}}\n\ndata: [DONE]\n\n" From 5e2e972f4d16b3c825e618f3a7fd1f11ada10da9 Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 30 Sep 2026 19:25:41 +0000 Subject: [PATCH 7/9] fix(providers/kimicode): omit whitespace-only previous_response_id on the wire A whitespace-only previous_response_id passes the TrimSpace guard but was forwarded unchanged: omitempty does not omit a non-empty whitespace string, and the upstream cannot resolve it. Clear the field on the copied request and assert its omission on the wire. --- internal/providers/kimicode/kimicode.go | 16 ++++++++++++---- internal/providers/kimicode/kimicode_test.go | 19 ++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/providers/kimicode/kimicode.go b/internal/providers/kimicode/kimicode.go index 1bbfca26a..e9b0f610c 100644 --- a/internal/providers/kimicode/kimicode.go +++ b/internal/providers/kimicode/kimicode.go @@ -95,16 +95,24 @@ func rejectPreviousResponseID(req *core.ResponsesRequest) error { // adaptResponsesRequest pins store to false: the service retains no // responses, so store=true fails upstream with a 400 (Postel's law — adapt -// instead of failing). +// instead of failing). A whitespace-only previous_response_id is treated as +// empty by rejectPreviousResponseID and cleared here, because omitempty does +// not omit a non-empty whitespace string and the upstream cannot resolve it. func adaptResponsesRequest(req *core.ResponsesRequest) *core.ResponsesRequest { if req == nil { return nil } - if req.Store == nil || !*req.Store { + whitespaceID := req.PreviousResponseID != "" && strings.TrimSpace(req.PreviousResponseID) == "" + if (req.Store == nil || !*req.Store) && !whitespaceID { return req } cp := *req - disabled := false - cp.Store = &disabled + if req.Store != nil && *req.Store { + disabled := false + cp.Store = &disabled + } + if whitespaceID { + cp.PreviousResponseID = "" + } return &cp } diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index 3db7c011a..8e488eb6a 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -88,6 +88,18 @@ func TestAdaptResponsesRequest(t *testing.T) { require.NotNil(t, got.Store) assert.False(t, *got.Store, "explicit store=false should stay false") }) + + t.Run("whitespace-only previous_response_id is cleared", func(t *testing.T) { + req := &core.ResponsesRequest{ + Model: "kimi-for-coding", + Input: "hi", + PreviousResponseID: " ", + } + got := adaptResponsesRequest(req) + require.NotSame(t, req, got, "adapted request should be a copy") + assert.Empty(t, got.PreviousResponseID) + assert.Equal(t, " ", req.PreviousResponseID, "original request was mutated") + }) } // responsesGoldenBody mirrors a real non-streaming /responses reply from the @@ -208,7 +220,9 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { t.Run("whitespace-only ID passes through", func(t *testing.T) { // The gateway and the chat-translation validator treat a - // whitespace-only ID as empty; the provider must behave the same. + // whitespace-only ID as empty; the provider must behave the same, + // and the unresolvable value must not reach the wire (omitempty + // does not omit a non-empty whitespace string). resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ Model: "kimi-for-coding", Input: "Say OK", @@ -217,6 +231,9 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, 1, capture.Count(), "whitespace-only ID is treated as empty") + wire := capture.Last(t).JSON(t) + _, present := wire["previous_response_id"] + assert.False(t, present, "whitespace-only ID must be omitted from the wire") }) t.Run("conversation reference is rejected", func(t *testing.T) { From 02fe4ff0520789273c37e4e6c36c4d4b69e62511 Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 30 Sep 2026 20:03:57 +0000 Subject: [PATCH 8/9] test(providers/kimicode): make request-count assertion order-independent The conversation-reference subtest asserted a fixed capture count, which only holds when the whitespace-only subtest ran first. Record the count before the rejected request instead (CodeRabbit review). --- internal/providers/kimicode/kimicode_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/providers/kimicode/kimicode_test.go b/internal/providers/kimicode/kimicode_test.go index 8e488eb6a..23eea1d8a 100644 --- a/internal/providers/kimicode/kimicode_test.go +++ b/internal/providers/kimicode/kimicode_test.go @@ -237,6 +237,7 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { }) t.Run("conversation reference is rejected", func(t *testing.T) { + before := capture.Count() resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ Model: "kimi-for-coding", Input: "Say OK", @@ -249,7 +250,7 @@ func TestResponses_RejectsPreviousResponseID(t *testing.T) { require.ErrorAs(t, err, &gatewayErr) assert.Equal(t, core.ErrorTypeInvalidRequest, gatewayErr.Type) assert.Contains(t, gatewayErr.Error(), "conversation") - assert.Equal(t, 1, capture.Count(), "conversation request must not reach the upstream") + assert.Equal(t, before, capture.Count(), "conversation request must not reach the upstream") }) } From 4c4e7a3a843dee29927e735b39684be125f96dea Mon Sep 17 00:00:00 2001 From: weselben Date: Wed, 30 Sep 2026 20:05:28 +0000 Subject: [PATCH 9/9] test(providers/openai): cover ChatCompatible.Compatible accessor --- .../providers/openai/chat_compatible_test.go | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 internal/providers/openai/chat_compatible_test.go diff --git a/internal/providers/openai/chat_compatible_test.go b/internal/providers/openai/chat_compatible_test.go new file mode 100644 index 000000000..975094664 --- /dev/null +++ b/internal/providers/openai/chat_compatible_test.go @@ -0,0 +1,21 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/enterpilot/gomodel/internal/providers" +) + +// Compatible must expose the adapter the chat-centric surface was built +// with, so providers serving native Responses use the same instance. +func TestChatCompatible_Compatible(t *testing.T) { + chat := NewChatCompatible("key", providers.ProviderOptions{}, CompatibleProviderConfig{ + ProviderName: "test", + BaseURL: "https://example.com/v1", + }) + require.NotNil(t, chat) + assert.Same(t, chat.compatible, chat.Compatible()) +}