Refactor: architecture refactoring - ADR 2 implemented#133
Refactor: architecture refactoring - ADR 2 implemented#133SantiagoDePolonia merged 36 commits intomainfrom
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds provider-native passthrough routes and runtime flags, ingress-frame capture with semantic envelopes, pervasive unknown-field preservation via ExtraFields and custom JSON (un)marshalling, batch/file semantic decoding, and end-to-end passthrough wiring across router, providers, guardrails, llmclient, server middleware, and tests. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Server as Echo Server
participant Ingress as IngressCapture
participant Semantic as SemanticEnvelope
participant Router as Provider Router
participant Provider as Provider
rect rgba(200,200,255,0.5)
Client->>Server: HTTP /p/{provider}/{endpoint} request
Server->>Ingress: capture frame (headers, body, route)
Ingress->>Semantic: BuildSemanticEnvelope(frame)
Semantic->>Server: attach envelope to context
Server->>Router: Router.Passthrough(ctx, providerType, PassthroughRequest)
Router->>Provider: Provider.Passthrough(ctx, req)
Provider-->>Router: PassthroughResponse (status, headers, body)
Router-->>Server: forward response
Server-->>Client: proxied HTTP response (streaming or non-SSE)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested labels
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Pull request overview
Implements ADR-0002 by introducing an ingress-layer transport capture (IngressFrame) and a best-effort semantic layer (SemanticEnvelope) to avoid repeated body reads, improve routing/validation, and preserve unknown JSON fields across adapters/providers. It also adds provider-native passthrough routing under /p/{provider}/{endpoint} with configuration toggles, and updates contract goldens + tests accordingly.
Changes:
- Add ingress capture middleware + semantic request helpers, and migrate model validation/audit logging to consume the shared ingress/semantic context.
- Preserve unknown JSON fields across core request/response types and the Responses↔Chat adapter (including nested/tool-call/content-part extras).
- Add provider passthrough plumbing (core types, router/provider implementations, server route registration, docs/config/tests).
Reviewed changes
Copilot reviewed 79 out of 80 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/tools.go | Pins swag CLI as a tool-only dependency. |
| tools/doc.go | Documents the purpose of the tools package. |
| tests/contract/testdata/golden/xai/chat_with_params.golden.json | Updates xAI golden to include new message fields. |
| tests/contract/testdata/golden/xai/chat_completion.golden.json | Updates xAI golden to include new message fields. |
| tests/contract/testdata/golden/openai/chat_with_tools.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| tests/contract/testdata/golden/openai/chat_with_params.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| tests/contract/testdata/golden/openai/chat_multimodal.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| tests/contract/testdata/golden/openai/chat_multi_turn.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| tests/contract/testdata/golden/openai/chat_json_mode.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| tests/contract/testdata/golden/openai/chat_completion_reasoning.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| tests/contract/testdata/golden/openai/chat_completion.golden.json | Updates OpenAI golden message fields (annotations, refusal). |
| internal/server/semantic_requests_test.go | Adds server-level tests for canonical decode + envelope caching behavior. |
| internal/server/semantic_requests.go | Adds semantic envelope helpers and canonical request decoding for handlers/middleware. |
| internal/server/route_params.go | Adds helper for converting Echo path values into a map. |
| internal/server/model_validation_test.go | Adds tests ensuring validation uses ingress/semantic data without reading live body. |
| internal/server/model_validation.go | Refactors model validation to use semantic/ingress hints and handle passthrough routes. |
| internal/server/ingress_test.go | Adds tests for ingress capture semantics (raw body capture, passthrough params, oversized bodies, multipart). |
| internal/server/ingress.go | Introduces ingress capture middleware + shared requestBodyBytes utility. |
| internal/server/http_test.go | Adds server tests for passthrough route enable/disable behavior. |
| internal/server/http.go | Registers ingress capture early; adds passthrough route registration and config toggles. |
| internal/providers/router_test.go | Extends mock provider + tests to cover router passthrough routing. |
| internal/providers/responses_adapter_test.go | Adds tests verifying preservation of opaque extras through adapters. |
| internal/providers/responses_adapter.go | Preserves extras through Responses→Chat conversion and deep-clones nested extras. |
| internal/providers/passthrough.go | Adds passthrough endpoint normalization + header cloning helpers. |
| internal/providers/openai/openai_test.go | Adds tests ensuring unknown field preservation + passthrough behavior. |
| internal/providers/openai/openai.go | Preserves unknown top-level fields when adapting o-series requests; adds OpenAI passthrough. |
| internal/providers/groq/groq_test.go | Adds test ensuring opaque fields survive Responses→Chat adapter path. |
| internal/providers/file_adapter_openai_compat.go | Refactors file ID validation + request helpers to reduce duplication. |
| internal/providers/anthropic/anthropic_test.go | Adds tests for batch URL normalization, shared translation, and passthrough. |
| internal/llmclient/client_test.go | Adds passthrough retry semantics tests. |
| internal/core/types_test.go | Adds JSON roundtrip tests verifying unknown field preservation. |
| internal/core/types.go | Adds ExtraFields (raw JSON) and selector helpers on key request/message types. |
| internal/core/semantic_test.go | Adds tests for building semantic envelopes across endpoint types. |
| internal/core/semantic_canonical_test.go | Adds tests for canonical decoding/caching and route metadata caching. |
| internal/core/semantic_canonical.go | Adds canonical operation codecs and route metadata caching helpers. |
| internal/core/semantic.go | Introduces SemanticEnvelope, caching, and transport-derived semantics builders. |
| internal/core/responses_json_test.go | Adds tests for unknown field preservation in Responses JSON. |
| internal/core/responses_json.go | Preserves unknown fields when unmarshalling/marshalling Responses requests + elements. |
| internal/core/responses.go | Adds ExtraFields + selector helper for Responses request and input elements. |
| internal/core/passthrough.go | Adds core passthrough request/response types and interfaces. |
| internal/core/message_json.go | Preserves unknown fields for messages/tool-calls/function calls via custom JSON (un)marshal. |
| internal/core/json_fields.go | Adds helpers for extracting/cloning/merging unknown JSON fields. |
| internal/core/ingress.go | Adds IngressFrame definition for immutable inbound capture. |
| internal/core/files.go | Adds file route semantic metadata + multipart enrichment helper. |
| internal/core/endpoints_test.go | Adds tests for endpoint classification and passthrough path parsing. |
| internal/core/endpoints.go | Centralizes endpoint classification and model-interaction detection. |
| internal/core/embeddings_json.go | Adds custom JSON (un)marshal to preserve unknown fields for embeddings requests. |
| internal/core/context.go | Adds context storage/accessors for ingress frames and semantic envelopes. |
| internal/core/chat_json.go | Adds custom JSON (un)marshal to preserve unknown fields for chat requests. |
| internal/core/chat_content.go | Preserves unknown fields on content parts and nested image/audio structures. |
| internal/core/batch_semantic_test.go | Adds tests for batch URL normalization and typed decode. |
| internal/core/batch_semantic.go | Adds batch item URL normalization + typed decode/dispatch helpers. |
| internal/core/batch_json_test.go | Adds tests for unknown field preservation in batch JSON. |
| internal/core/batch_json.go | Adds custom JSON (un)marshal to preserve unknown fields in batch requests/items. |
| internal/core/batch.go | Adds batch semantic metadata + unknown-field support in batch request/items. |
| internal/auditlog/stream_wrapper.go | Switches model-interaction classification to core.IsModelInteractionPath. |
| internal/auditlog/reader_mongodb.go | Deduplicates MongoDB lookup logic via shared helper. |
| internal/auditlog/middleware.go | Uses ingress frame body capture when available to avoid rereading request streams. |
| internal/auditlog/auditlog_test.go | Adds tests verifying middleware uses ingress frame instead of reading request body. |
| internal/app/app.go | Wires new passthrough config flags into server config and logs feature status. |
| internal/admin/handler.go | Refactors usage endpoints to share response + nil-reader handling logic. |
| go.sum | Adds indirect module sums required by updated dependencies/tooling. |
| go.mod | Adds new indirect deps (e.g., from swag/tooling). |
| docs/adr/0002-ingress-frame-and-semantic-envelope.md | Updates ADR text and clarifies scope/behavior for opaque preservation. |
| config/config_test.go | Adds env var clearing and defaults assertions for new server flags. |
| config/config_helpers_test.go | Adds env override tests for new passthrough flags. |
| config/config.go | Adds passthrough config options and defaults. |
| config/config.example.yaml | Documents new passthrough configuration settings. |
| cmd/gomodel/docs/swagger.json | Updates generated swagger with passthrough endpoint documentation. |
| cmd/gomodel/docs/docs.go | Updates generated swagger template with passthrough endpoint documentation. |
| .golangci.yml | Tightens dupl threshold. |
| .env.template | Documents new passthrough environment variables. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // DescribeEndpointPath classifies a request path for ADR-0002 ingress handling. | ||
| func DescribeEndpointPath(path string) EndpointDescriptor { | ||
| path, _, _ = strings.Cut(strings.TrimSpace(path), "?") | ||
|
|
||
| switch { | ||
| case path == "/v1/chat/completions": | ||
| return EndpointDescriptor{ | ||
| ModelInteraction: true, | ||
| IngressManaged: true, | ||
| Dialect: "openai_compat", | ||
| Operation: "chat_completions", | ||
| BodyMode: BodyModeJSON, | ||
| } |
There was a problem hiding this comment.
DescribeEndpointPath matches /v1/chat/completions only exactly. If the router/middleware ever receives a trailing-slash variant (e.g. /v1/chat/completions/), it will be treated as non-model-interaction and skip ingress capture / validation / audit filtering. Consider using the existing matchesEndpointPath helper (as done for /v1/responses) or normalizing a single trailing slash before switching.
There was a problem hiding this comment.
Actionable comments posted: 34
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cmd/gomodel/docs/swagger.json`:
- Around line 535-564: The Swagger path /p/{provider}/{endpoint} incorrectly
models multi-segment endpoints because OpenAPI 2.0 path params are
single-segment; update the `@Router` annotations in internal/server/handlers.go
(the lines containing "@Router /p/{provider}/{endpoint}" and the parameter docs
for "endpoint") to either document that endpoint must be URL-encoded when
containing slashes or change the route signature to capture the remainder via a
query or surrogate path (e.g., use a single catch-all encoded param or redesign
to /p/{provider} with a query param endpoint), then regenerate the OpenAPI
artifacts (run the doc generation that produces cmd/gomodel/docs/swagger.json
and cmd/gomodel/docs/docs.go) so the swagger.json reflects the corrected
parameter semantics.
In `@config/config_test.go`:
- Around line 29-30: Add tests to config/config_test.go that verify cascade
precedence for the new passthrough flags: ensure code defaults are overridden by
values in config.yaml (including variable expansion forms like ${VAR} and
${VAR:-default}) and that environment variables (ENABLE_PROVIDER_PASSTHROUGH and
NORMALIZE_PASSTHROUGH_V1_PREFIX) always win; specifically add cases that set
these keys in YAML (server.enable_provider_passthrough,
server.normalize_passthrough_v1_prefix), then override via os.Setenv before
calling Load(), and assert the resulting config matches the env value rather
than the YAML or default, and also add a test that YAML expansion works when
using ${VAR} and ${VAR:-default} for these keys.
In `@internal/auditlog/reader_mongodb.go`:
- Around line 236-240: The error label for findFirstByField is inconsistent:
findByResponseID passes "response id" while findByPreviousResponseID passes
"previous_response_id"; update one to match the other (pick a consistent format
like snake_case or human-readable) so both calls use the same label style—e.g.,
change the argument in findByResponseID or findByPreviousResponseID to the
chosen format when calling r.findFirstByField to ensure uniform error messages
across functions.
In `@internal/core/batch_json_test.go`:
- Around line 28-55: The test currently only asserts that x_top and x_item_flag
are objects, which allows empty objects to pass and misses regressions; update
the assertions to verify specific nested payload contents both on the original
req and after roundTrip JSON marshal/unmarshal: check concrete fields/values
inside req.ExtraFields["x_top"] and req.Requests[0].ExtraFields["x_item_flag"]
(e.g., expected keys/values) before calling json.Marshal, then after
unmarshalling verify decoded["x_top"] and first["x_item_flag"] contain the same
expected nested keys and values (use req, req.ExtraFields, req.Requests,
json.Marshal, json.Unmarshal, decoded, requests, first to locate the spots).
In `@internal/core/batch_semantic.go`:
- Around line 28-60: Add proper Go doc comments for the exported accessors
ChatRequest, ResponsesRequest, EmbeddingRequest, and ModelSelector on
DecodedBatchItemRequest: each comment should start with the method name (e.g.,
"ChatRequest ..."), briefly describe what it returns and the nil-receiver
behavior (ChatRequest/ResponsesRequest/EmbeddingRequest return nil if the
receiver is nil or if the underlying Request is not that type; ModelSelector
returns the selected model/provider or an error if the decoded request is nil or
unsupported), and mention any important error conditions for ModelSelector;
ensure comments follow Go doc conventions and are placed immediately above each
method.
In `@internal/core/chat_json.go`:
- Around line 5-83: Add a unit test that directly exercises ChatRequest's
UnmarshalJSON and MarshalJSON round-trip: write a new internal/core test (e.g.,
chat_json_test.go) that constructs JSON containing known fields (model,
messages, etc.) plus unknown top-level keys and unknown nested keys inside
objects in tools/messages, then call json.Unmarshal into a ChatRequest (invoking
ChatRequest.UnmarshalJSON) and assert those unknown keys are present in
r.ExtraFields, and finally json.Marshal the ChatRequest (invoking MarshalJSON)
and assert the serialized bytes still contain the unknown keys (verifying
marshalWithUnknownJSONFields round-trips the ExtraFields). Ensure the test
references ChatRequest, UnmarshalJSON, MarshalJSON, ExtraFields,
extractUnknownJSONFields and marshalWithUnknownJSONFields to validate the
contract.
In `@internal/core/embeddings_json.go`:
- Around line 5-53: Add a unit test that verifies EmbeddingRequest's custom
UnmarshalJSON/MarshalJSON round-trip preserves unknown top-level fields in
ExtraFields: create an EmbeddingRequest JSON blob containing known fields
(model, provider, input, encoding_format, dimensions) plus one or more unknown
fields, unmarshal it into EmbeddingRequest (testing UnmarshalJSON), assert known
fields are populated and ExtraFields contains the unknown keys, then marshal it
back (testing MarshalJSON via marshalWithUnknownJSONFields) and assert the
resulting JSON still contains the unknown fields with their values; reference
EmbeddingRequest, UnmarshalJSON, MarshalJSON, ExtraFields,
extractUnknownJSONFields, and marshalWithUnknownJSONFields when locating code to
test.
In `@internal/core/endpoints_test.go`:
- Around line 5-39: Add a negative test for discovery routes in
TestDescribeEndpointPath: call DescribeEndpointPath("/v1/models") and assert
that the returned struct has ModelInteraction == false (and optionally verify
Dialect/Operation/IngressManaged/BodyMode as appropriate for discovery routes);
update the tests table or add a separate t.Run case inside
TestDescribeEndpointPath that checks DescribeEndpointPath("/v1/models") does not
mark the path as a model interaction.
In `@internal/core/endpoints.go`:
- Around line 24-25: DescribeEndpointPath currently sets
EndpointDescriptor.BodyMode without the HTTP method, causing bodyless GET/DELETE
routes under /v1/batches* and /v1/files* to be misclassified; update the
classifier so it either accepts an additional parameter (HTTP method) or returns
a descriptor without BodyMode and provide a helper to derive BodyMode after the
action is known. Specifically, modify the DescribeEndpointPath signature or
behavior to include the request method (or omit BodyMode) and update any call
sites that use DescribeEndpointPath/EndpointDescriptor to compute BodyMode later
based on method+path (references: DescribeEndpointPath, EndpointDescriptor,
BodyMode).
In `@internal/core/files.go`:
- Around line 60-77: Add a proper Go doc comment for the exported function
EnrichFileCreateRequestSemantic describing what the function does (enriches a
FileRequestSemantic from multipart metadata), its parameters (req
*FileRequestSemantic, reader FileMultipartMetadataReader), the conditions under
which it returns req unchanged (nil req, wrong Action, or nil reader), and what
fields it populates (Provider, Purpose, Filename) before returning the enriched
*FileRequestSemantic; place the comment directly above the
EnrichFileCreateRequestSemantic function declaration following Go doc comment
conventions.
In `@internal/core/ingress.go`:
- Around line 6-17: Add brief doc comments to the IngressFrame struct fields to
clarify purpose and usage: annotate Method, Path, RouteParams, QueryParams,
Headers, ContentType to state what they represent; explain RawBody (contains the
request body bytes when within size limits) versus RawBodyTooLarge (flag set
when body exceeded capture limit and RawBody is truncated/empty); document
RequestID usage and TraceMetadata (map of tracing-related key/value pairs such
as trace/span ids or sampling metadata). Update the IngressFrame definition to
include those field comments so future maintainers understand intent and edge
cases.
In `@internal/core/message_json.go`:
- Around line 119-186: Add Go doc comments for the exported JSON methods to
match project conventions: add a comment starting with "ToolCall.UnmarshalJSON"
immediately above the ToolCall.UnmarshalJSON method describing that it
unmarshals a ToolCall from JSON and preserves unknown fields into ExtraFields,
add "ToolCall.MarshalJSON" above ToolCall.MarshalJSON explaining it marshals a
ToolCall to JSON including ExtraFields, and likewise add
"FunctionCall.UnmarshalJSON" and "FunctionCall.MarshalJSON" comments above their
respective methods indicating they unmarshal/marshal FunctionCall and
preserve/emit ExtraFields; keep the comments short, start with the exact method
names, and follow Go doc style.
In `@internal/core/responses_json.go`:
- Around line 171-185: The current call to extractUnknownJSONFields uses a
global exclusion list shared by all ResponsesInputElement variants and thus
drops fields that are unknown for the active variant but known for other
variants; change the code to compute the exclusion slice based on e.Type (the
active variant) before calling extractUnknownJSONFields so only the keys
actually consumed by that variant are excluded. Locate the unmarshalling logic
that sets e.ExtraFields (where extractUnknownJSONFields is called) and replace
the fixed list with a small switch/if on e.Type that builds the appropriate
[]string of known keys for each variant (include "type" plus the
variant-specific keys such as "id","role","status","content" for message-like
variants, and "call_id","name","arguments","output" for call-like variants),
then pass that computed slice to extractUnknownJSONFields and assign the
returned map to e.ExtraFields.
In `@internal/core/responses.go`:
- Around line 5-24: Update the Go doc comments on the exported types
ResponsesRequest and ResponsesInputElement to document the ExtraFields contract:
state that any unknown JSON members encountered during unmarshaling are
preserved in ExtraFields (map[string]json.RawMessage) and will be included when
marshaling so callers can rely on round-tripping of unknown fields; mention that
ExtraFields is ignored by Swagger (swaggerignore) and that consumers should
prefer typed fields when present but can read/write ExtraFields for extensions.
Ensure the new text appears immediately above the type definitions for
ResponsesRequest and ResponsesInputElement.
In `@internal/core/semantic_canonical.go`:
- Around line 198-211: Add a Go doc comment above the exported function
DecodeCanonicalSelector that briefly describes its purpose, parameters and
return values; mention that it decodes a canonical request from body using the
codec resolved by canonicalOperationCodecFor on the provided *SemanticEnvelope
(env), and returns the model and provider strings along with an ok boolean
(false on nil env, codec lookup failure, or decode error), and reference
semanticSelectorFromCanonicalRequest as the final step of the operation.
In `@internal/guardrails/provider.go`:
- Around line 206-267: The code in patchChatMessagesJSON consumes
systemOriginals from the front, causing prepended system messages to inherit the
wrong original envelope; update the algorithm to match existing system messages
from the tail (i.e., consume systemOriginals from the end when aligning with
modified system messages) before treating remaining system entries as
insertions, and apply the same tail-based logic to the other remapping loop
referenced (the similar implementation at lines ~505-558) so both use the same
stable tail-match strategy; ensure you still call patchRawChatMessage for
matched originals and json.Marshal for truly new system messages, and update the
nextSystem/indices handling accordingly.
In `@internal/llmclient/client_test.go`:
- Around line 400-486: The DoPassthrough retry behavior currently applies to all
methods; change DoPassthrough to only perform retries for explicit replay-safe
methods or when the caller opts in: check the Request.Method inside
DoPassthrough (the function named DoPassthrough and its call to c.isRetryable())
and if Method is not a replay-safe method (e.g., only allow GET and HEAD) then
bypass the retry logic and return the single upstream response, or alternatively
require an explicit Request flag (e.g., AllowRetries) to enable retries for
unsafe methods; update the branch that calls c.isRetryable() to first validate
Request.Method (or the opt-in flag) so non-idempotent methods are never
automatically retried.
In `@internal/llmclient/client.go`:
- Around line 193-204: The circuit-breaker half-open probe can remain unresolved
when a probe request returns a non-retryable, non-5xx status (e.g. 400/401/404)
because DoRaw/DoStream return without calling RecordSuccess/RecordFailure;
update the shared completion path so that any admitted half-open probe (acquired
via c.circuitBreaker.acquire and stored in scope.halfOpenProbe) is always
resolved: call c.circuitBreaker.RecordSuccess(probe) for non-retryable completed
responses (the same outcome DoPassthrough treats as success) and call
RecordFailure for retryable/5xx errors, ensuring finishRequest still runs (use
requestScope / scope handling in the completion logic used around DoRaw,
DoStream, and DoPassthrough to invoke RecordSuccess/RecordFailure
appropriately).
- Around line 401-459: DoPassthrough currently retries every request body which
can cause duplicate provider-side effects for non-idempotent methods; update
DoPassthrough to only perform the retry loop when the request is safe to replay
by checking request idempotency up-front (e.g., allow retries when
Request.Method is one of idempotent methods like GET/HEAD/PUT/OPTIONS or when an
explicit idempotency key header is present such as "Idempotency-Key"); gate the
retry behavior before entering the for loop that uses maxAttempts/waitForRetry
and before calling isRetryable/doHTTPRequest, and ensure non-idempotent methods
immediately return the first response/error (or set maxAttempts=1) so functions
and symbols to change include DoPassthrough, the for attempt loop using
maxAttempts, waitForRetry, isRetryable, and the check for req.Headers
(idempotency key) or req.Method.
In `@internal/providers/anthropic/anthropic.go`:
- Around line 163-167: Update the PassthroughResponse type comment to document
the ownership contract: state that the Body field is an io.ReadCloser and that
callers are responsible for closing it when finished (i.e., the provider returns
a PassthroughResponse with Body set, and the receiver must call Close on that
ReadCloser). Add this note to the comment above the core.PassthroughResponse
struct in internal/core/passthrough.go so all providers (and callers like
handlers) see the requirement.
In `@internal/providers/file_adapter_openai_compat.go`:
- Around line 17-52: Add regression tests for validatedOpenAICompatibleFileID
and doOpenAICompatibleFileIDRequest in a *_test.go: verify that whitespace-only
IDs return an invalid-request error, that IDs with surrounding whitespace are
trimmed before being sent (use a fake llmclient.Client to assert the Endpoint
contains the trimmed id), that response objects returned by
doOpenAICompatibleFileIDRequest carry the normalized (trimmed) ID, and that when
upstream responses have an empty Object field the function synthesizes
defaultObject (test both core.FileObject and core.FileDeleteResponse branches).
Use table-driven tests and a mock client to simulate upstream responses and
errors.
In `@internal/providers/openai/openai.go`:
- Around line 101-129: adaptForOSeries currently swallows
json.Marshal/json.Unmarshal failures and returns lossy payloads; change it to
return an error when (un)marshalling fails so the caller can abort: update
adaptForOSeries signature to return (any, error), keep the successful path that
unmarshals into var raw map[string]json.RawMessage, map "max_tokens" ->
"max_completion_tokens" and delete "temperature", but on json.Marshal or
json.Unmarshal errors return core.GatewayError with Category set to
core.InvalidRequestError (include the underlying error message), rather than
returning a partial map or the original req so opaque fields are not dropped
silently; update callers to handle the (any, error) result accordingly.
In `@internal/providers/responses_adapter.go`:
- Around line 547-579: The map[string]string branch in
normalizeResponsesImageURLForChat returns an ImageURLContent but drops unknown
keys instead of populating ExtraFields; update that branch to capture extra
fields the same way as the map[string]interface{} branch by converting the
string map into a form rawJSONMapFromUnknownKeys can use (or by building
ExtraFields from keys other than "url","detail","media_type") and set
ExtraFields on the returned core.ImageURLContent; apply the same change to the
analogous function handling audio URLs (the corresponding branch around lines
582-605) so both map[string]string and map[string]interface{} paths preserve
nested/unknown keys via rawJSONMapFromUnknownKeys.
In `@internal/providers/router_test.go`:
- Around line 737-769: Add unit tests covering passthrough error cases: (1)
Unknown provider — call router.Passthrough with a provider name not registered
in newMockLookup and assert an error is returned and that it can be cast to
*core.GatewayError (use errors.As); (2) Provider error propagation — register a
mockProvider that returns an error from its Passthrough implementation, invoke
router.Passthrough and assert that the same/provider error is propagated; (3)
Empty registry — construct router via NewRouter(newMockLookup() without any
models) and call router.Passthrough to assert errors.Is(err,
ErrRegistryNotInitialized). Reference router.Passthrough, NewRouter,
newMockLookup, mockProvider, ErrRegistryNotInitialized and core.GatewayError
when locating/creating these tests.
In `@internal/providers/router.go`:
- Around line 60-71: resolveProviderType currently uses providerByType (which
only returns providers that own discovered models) causing provider-typed routes
to fail when a provider has no models; change resolveProviderType to look up
providers from a dedicated provider-type registry instead of providerByType
(e.g., use or add a map/lookup like r.providerRegistry or r.providersByType and
a getter method such as r.providerByTypeRegistry/FindProviderByType) and update
resolveProviderType to call that registry lookup; ensure the registry is
initialized before routing checks (keep the existing checkReady call) so
provider-typed routes work even when /models sync yields zero models.
In `@internal/server/handlers.go`:
- Around line 371-382: The handler currently fully buffers uploads via
requestBodyBytes and PassthroughRequest.Body, which risks OOM; change to stream
the request instead of reading into memory by updating core.PassthroughRequest
to accept an io.ReadCloser or io.Reader (e.g., Body io.ReadCloser), update
passthroughProvider.Passthrough to take that streamed Body, and in handlers.go
pass c.Request().Body (or an io.LimitedReader wrapping it to enforce a hard size
cap) instead of calling requestBodyBytes; also adjust downstream implementations
(providers and any callers of Passthrough) to read/close the stream and to
respect the optional size limit.
- Around line 224-262: The passthrough header logic in
buildPassthroughHeaders/skipPassthroughHeader/copyPassthroughResponseHeaders
leaks sensitive request and response metadata (Cookie, Forwarded, X-Forwarded-*,
and Set-Cookie) and ignores hop-by-hop headers named in the Connection header;
update these functions by splitting request vs response handling so request-side
filter explicitly excludes "Cookie", "Forwarded", and any "X-Forwarded-*"
headers, and response-side filter blocks "Set-Cookie" and any headers listed in
the incoming Connection header; also modify skipPassthroughHeader to treat those
names as skipped and ensure copyPassthroughResponseHeaders consults the
Connection header values and uses
http.CanonicalHeaderKey(strings.TrimSpace(...)) for comparisons while preserving
existing sensitive-header redaction behavior in audit logs (do not log header
values).
- Around line 291-329: The Swagger annotations for the passthrough handler are
incorrect: update the API docs for the passthrough endpoints (references:
proxyPassthroughResponse, ProviderPassthrough handler and
passthroughStreamAuditPath usages) to declare the path parameter as a
catch-all/wildcard (not a single-segment param) and mark responses as opaque
passthrough (allow arbitrary status codes and content-types, e.g., binary/any
and SSE/stream) rather than a single JSON schema; then regenerate docs by
running make swagger so generated clients reflect the actual runtime behavior.
In `@internal/server/http_test.go`:
- Around line 523-547: Update the TestProviderPassthroughRoute_EnabledByDefault
test to also exercise the normalized v1 prefix: send an additional httptest
request to "/p/openai/v1/responses" (same body and headers) and assert
srv.ServeHTTP returns http.StatusOK and that mock.lastPassthroughProvider ==
"openai"; this ensures the NormalizePassthroughV1Prefix behavior is covered
alongside the existing "/p/openai/responses" path and prevents routing
regressions.
- Around line 549-565: Update
TestProviderPassthroughRoute_DisabledRequiresAuthBefore404 to also assert
behavior when authenticated: create a second httptest request to
"/p/openai/responses" with the same JSON body but include the correct MasterKey
header (matching Config.MasterKey), call srv.ServeHTTP on a new recorder, assert
the response status is http.StatusNotFound (404), and verify your mockProvider
passthrough handler (mockProvider) was NOT invoked (e.g., check a called flag or
counter on mockProvider) to confirm DisableProviderPassthrough prevents routing
even for authenticated requests.
In `@internal/server/http.go`:
- Around line 45-46: Rename the confusing flag
DisablePassthroughV1PrefixNormalization to a positively named
EnablePassthroughV1PrefixNormalization and invert its semantics (true =>
normalization enabled) throughout the codebase: update the struct field name,
all references/usages, any config parsing or defaults that set/expect the old
flag, and tests; where code currently checks
DisablePassthroughV1PrefixNormalization (e.g., conditional branches that skip
normalization), flip the condition to use EnablePassthroughV1PrefixNormalization
and invert the boolean logic so behavior is preserved but the flag reads
positively. Ensure documentation/comments and any environment/config keys are
updated to match the new name and semantics.
In `@internal/server/ingress_test.go`:
- Around line 29-75: Add a test that exercises IngressCapture when the
X-Request-ID header is absent: create a request without setting "X-Request-ID",
run it through the same IngressCapture middleware (use the existing pattern with
capturedFrame := core.GetIngressFrame(c.Request().Context()) inside the
handler), then assert that capturedFrame.RequestID is non-empty and is a valid
UUID (e.g., parse it with a UUID parser or assert it matches UUID format). Keep
the rest of the checks minimal (ensure downstream body still equals the original
body) and place this as a new test case or an additional test function alongside
TestIngressCapture_SetsFrameAndSemanticEnvelope.
In `@internal/server/ingress.go`:
- Around line 88-99: The helper extractTraceMetadata drops additional header
lines by only using values[0]; change it to preserve all header values by
joining the values slice (preserving order) into a single string (e.g.
strings.Join(values, ",")) and use that joined string for the TrimSpace and
assignment to metadata[key]; keep the existing keys slice (traceHeaders) and
return behavior unchanged so callers of extractTraceMetadata get the full
combined header value for Tracestate/Baggage/Traceparent.
- Around line 32-43: IngressFrame.RequestID is being populated only from
req.Header but the canonical ID lives in req.Context(); change the logic that
constructs core.IngressFrame (where RequestID is set) to first attempt to read
the request ID from req.Context(), then fall back to
req.Header.Get("X-Request-ID"), and if still empty generate a new UUID (v4) and
store it back into the request context (and set the X-Request-ID header) so
subsequent providers and audits see the same canonical ID; update any helper
that extracts/injects the request ID into context (or add one) and ensure you
use that context-derived value for IngressFrame.RequestID.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 48d1907f-8157-422c-8be4-ad6340308130
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (79)
.env.template.golangci.ymlcmd/gomodel/docs/docs.gocmd/gomodel/docs/swagger.jsonconfig/config.example.yamlconfig/config.goconfig/config_helpers_test.goconfig/config_test.godocs/adr/0002-ingress-frame-and-semantic-envelope.mdgo.modinternal/admin/handler.gointernal/app/app.gointernal/auditlog/auditlog_test.gointernal/auditlog/middleware.gointernal/auditlog/reader_mongodb.gointernal/auditlog/stream_wrapper.gointernal/core/batch.gointernal/core/batch_json.gointernal/core/batch_json_test.gointernal/core/batch_semantic.gointernal/core/batch_semantic_test.gointernal/core/chat_content.gointernal/core/chat_json.gointernal/core/context.gointernal/core/embeddings_json.gointernal/core/endpoints.gointernal/core/endpoints_test.gointernal/core/files.gointernal/core/ingress.gointernal/core/json_fields.gointernal/core/message_json.gointernal/core/passthrough.gointernal/core/responses.gointernal/core/responses_json.gointernal/core/responses_json_test.gointernal/core/semantic.gointernal/core/semantic_canonical.gointernal/core/semantic_canonical_test.gointernal/core/semantic_test.gointernal/core/types.gointernal/core/types_test.gointernal/guardrails/provider.gointernal/guardrails/provider_test.gointernal/llmclient/client.gointernal/llmclient/client_test.gointernal/providers/anthropic/anthropic.gointernal/providers/anthropic/anthropic_test.gointernal/providers/anthropic/request_translation.gointernal/providers/file_adapter_openai_compat.gointernal/providers/groq/groq_test.gointernal/providers/openai/openai.gointernal/providers/openai/openai_test.gointernal/providers/passthrough.gointernal/providers/responses_adapter.gointernal/providers/responses_adapter_test.gointernal/providers/router.gointernal/providers/router_test.gointernal/server/handlers.gointernal/server/handlers_test.gointernal/server/http.gointernal/server/http_test.gointernal/server/ingress.gointernal/server/ingress_test.gointernal/server/model_validation.gointernal/server/model_validation_test.gointernal/server/route_params.gointernal/server/semantic_requests.gointernal/server/semantic_requests_test.gotests/contract/testdata/golden/openai/chat_completion.golden.jsontests/contract/testdata/golden/openai/chat_completion_reasoning.golden.jsontests/contract/testdata/golden/openai/chat_json_mode.golden.jsontests/contract/testdata/golden/openai/chat_multi_turn.golden.jsontests/contract/testdata/golden/openai/chat_multimodal.golden.jsontests/contract/testdata/golden/openai/chat_with_params.golden.jsontests/contract/testdata/golden/openai/chat_with_tools.golden.jsontests/contract/testdata/golden/xai/chat_completion.golden.jsontests/contract/testdata/golden/xai/chat_with_params.golden.jsontools/doc.gotools/tools.go
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/llmclient/client.go (1)
154-163:⚠️ Potential issue | 🔴 CriticalDisable
DoRawretries whenRawBodyReaderis used.
RawBodyReaderis documented as one-shot, butDoRawstill enters the normal retry loop. After the first attempt, the reader may already be exhausted, so later attempts can send an empty or partial body to the provider.🛠️ Minimal guard
func (c *Client) DoRaw(ctx context.Context, req Request) (*Response, error) { scope, err := c.beginRequest(ctx, req, false) if err != nil { return nil, err } ctx = scope.ctx var lastErr error var lastStatusCode int maxAttempts := c.maxAttempts() + if req.RawBodyReader != nil { + maxAttempts = 1 + } for attempt := 0; attempt < maxAttempts; attempt++ {Also applies to: 299-310, 582-584
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/llmclient/client.go` around lines 154 - 163, The DoRaw retry loop must be disabled when Request.RawBodyReader is set because that reader is one-shot; update the retry logic in the DoRaw function (and any other retry sites referenced around the existing attempts at lines similar to 299-310 and 582-584) to detect if req.RawBodyReader != nil and in that case avoid multiple attempts (e.g., set maxAttempts=1 or skip the retry branch) so the request is only sent once; also ensure the code treats RawBodyReader as mutually exclusive with Body/RawBody as already documented in the Request struct to avoid ambiguous behavior.internal/server/http.go (1)
87-95:⚠️ Potential issue | 🟠 MajorAlso block metrics paths under
/p/to prevent passthrough auth bypass.The conflict check only protects
/v1/*, but this PR adds auth-protected passthrough routes at/p/:provider/*. If an operator configures metrics to something like/p/openai/models, Echo will register that exact GET route first, add it toauthSkipPaths, and shadow the passthrough endpoint for that path.🔒 Proposed guard
- if metricsPath == "/v1" || strings.HasPrefix(metricsPath, "/v1/") { + if metricsPath == "/v1" || strings.HasPrefix(metricsPath, "/v1/") || + ((cfg == nil || !cfg.DisableProviderPassthrough) && + (metricsPath == "/p" || strings.HasPrefix(metricsPath, "/p/"))) { slog.Warn("metrics endpoint conflicts with API routes, using /metrics instead", "configured", cfg.MetricsEndpoint, "normalized", metricsPath) metricsPath = "/metrics" }Also applies to: 193-201
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/server/http.go` around lines 87 - 95, The metrics conflict check should also block any configured metricsPath that targets passthrough routes under /p/ to avoid auth bypass: update the guard that currently checks metricsPath against "/v1" and strings.HasPrefix(metricsPath, "/v1/") to also check for metricsPath == "/p" or strings.HasPrefix(metricsPath, "/p/"), log the same warning (include cfg.MetricsEndpoint and normalized metricsPath) and set metricsPath = "/metrics" before appending to authSkipPaths; make the identical change to the other metrics-guard instance that uses the same variables (metricsPath, cfg.MetricsEndpoint, authSkipPaths) in this file.
♻️ Duplicate comments (6)
internal/providers/file_adapter_openai_compat_test.go (1)
20-57:⚠️ Potential issue | 🟠 MajorThe earlier regression-test gap is only partially closed.
The new suite validates trimmed
/files/{id}JSON requests, but it still never assertsr.Methodand it does not exerciseGetOpenAICompatibleFileContent, which now trims IDs on a separateDoRawpath and returns the normalized ID. A regression in DELETE dispatch or/files/{id}/contentnormalization would still pass here.As per coding guidelines,
**/*_test.go: Add or update tests for any behavior change.Also applies to: 59-163
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/providers/file_adapter_openai_compat_test.go` around lines 20 - 57, The test must assert HTTP method and exercise the DoRaw path used by GetOpenAICompatibleFileContent so normalization is fully covered: update TestValidatedOpenAICompatibleFileID (or add a sibling test) to have the test server verify r.Method for the JSON /files/{id} request (e.g., assert GET or DELETE as appropriate in the handler), and add a case that calls GetOpenAICompatibleFileContent with an id containing surrounding whitespace so the handler for /files/{id}/content (the DoRaw path) asserts the request method and returns the normalized id in the response body; then assert the returned id equals the trimmed/normalized value and keep the existing GatewayError assertions for invalid (whitespace-only) ids. Ensure you reference validatedOpenAICompatibleFileID and GetOpenAICompatibleFileContent/DoRaw when locating where to add these checks.internal/core/chat_json_test.go (1)
72-83:⚠️ Potential issue | 🟡 MinorAssert nested extra-field values, not just object presence.
These checks still pass if
x_message_meta,x_function_meta, orx_traceround-trip as empty objects. Assert the expected nested values both before and afterjson.Marshalso the test catches payload-loss regressions.Based on learnings:
Applies to **/*_test.go : Add or update tests for any behavior changeAlso applies to: 97-117
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/chat_json_test.go` around lines 72 - 83, The test currently only asserts presence of x_message_meta, x_function_meta, and related extra-field objects; update the assertions in Test (around req.Messages[0], req.Messages[0].ToolCalls, and req.Messages[0].ToolCalls[0].Function) to validate specific nested keys/values (e.g., expected fields inside ExtraFields["x_message_meta"], ExtraFields["x_tool_call"], and Function.ExtraFields["x_function_meta"] and any x_trace entries) both before and after json.Marshal/unmarshal, ensuring the nested values equal the expected strings/structures instead of merely non-nil objects so empty-object regressions are caught.internal/core/embeddings_json_test.go (1)
67-72:⚠️ Potential issue | 🟡 MinorAssert the nested
x_trace.idafter the round-trip.This still passes if
x_tracesurvives as{}. Verify the nested payload value as well so the test catches object-content loss, not just object presence.Based on learnings:
Applies to **/*_test.go : Add or update tests for any behavior change🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/embeddings_json_test.go` around lines 67 - 72, The test currently only asserts that decoded["x_trace"] is an object; update the test to also assert the nested id field survives the round-trip by casting decoded["x_trace"] to a map (e.g., xTraceMap := decoded["x_trace"].(map[string]any)) and then checking xTraceMap["id"] == <original expected id value> (or t.Fatalf on mismatch). Ensure you reference decoded and the nested "id" key so the test fails if the object is empty but missing its expected content.internal/server/ingress_test.go (1)
112-143:⚠️ Potential issue | 🟡 MinorAssert the generated ID is propagated to context and header too.
This only proves
IngressFrame.RequestIDis populated. A regression incore.WithRequestID(...)or theX-Request-IDheader writeback would still pass, even though downstream providers/audit logs depend on both.🧪 Proposed assertion additions
if _, parseErr := uuid.Parse(capturedFrame.RequestID); parseErr != nil { t.Fatalf("generated request id is not a valid UUID: %v", parseErr) } + assert.Equal(t, capturedFrame.RequestID, core.GetRequestID(c.Request().Context())) + assert.Equal(t, capturedFrame.RequestID, c.Request().Header.Get("X-Request-ID")) assert.JSONEq(t, reqBody, downstreamBody)As per coding guidelines,
{internal/server/**/*.go,internal/providers/**/*.go}: Auto-generateX-Request-IDas UUID if not present in request and propagate through context to providers and audit logs.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/server/ingress_test.go` around lines 112 - 143, The test TestIngressCapture_GeneratesRequestIDWhenMissing should also assert that the generated RequestID is written back to the HTTP header and stored in the request context so downstream code sees the same ID: after calling handler(c) fetch the X-Request-ID header from rec.Result().Header.Get("X-Request-ID") and assert it equals capturedFrame.RequestID, and also retrieve the value from context via core.GetRequestID(c.Request().Context()) or the equivalent context accessor used by core.WithRequestID(...) and assert that equals capturedFrame.RequestID; ensure you reference the existing middleware IngressCapture, capturedFrame from core.GetIngressFrame, and the X-Request-ID header in these new assertions.internal/server/handlers.go (1)
224-236:⚠️ Potential issue | 🔴 CriticalStrip request headers named by
Connectionbefore passthrough.The response copy path honors dynamic hop-by-hop headers, but the request builder still forwards headers nominated by the incoming
Connectionfield. A request likeConnection: X-DebugplusX-Debug: ...will currently leak that hop-by-hop metadata to the provider.🔐 Suggested fix
func buildPassthroughHeaders(src http.Header) map[string]string { if len(src) == 0 { return nil } + connectionHeaders := passthroughConnectionHeaders(src) dst := make(map[string]string, len(src)) for key, values := range src { canonicalKey := http.CanonicalHeaderKey(strings.TrimSpace(key)) if skipPassthroughRequestHeader(canonicalKey) || len(values) == 0 { continue } + if _, hopByHop := connectionHeaders[canonicalKey]; hopByHop { + continue + } dst[canonicalKey] = strings.Join(values, ", ") }Also applies to: 259-279
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/server/handlers.go` around lines 224 - 236, The buildPassthroughHeaders function currently forwards headers named in the incoming Connection header; parse the src "Connection" header values, canonicalize each token (use http.CanonicalHeaderKey and strings.TrimSpace) and treat those tokens as hop-by-hop names to skip just like skipPassthroughRequestHeader does; update buildPassthroughHeaders (and the similar logic around the other block that covers lines 259-279) to ignore any header whose canonical name appears in the parsed Connection list so no Connection-nominated headers (e.g. X-Debug) are copied into dst.internal/guardrails/provider.go (1)
234-245:⚠️ Potential issue | 🟠 MajorTail-only system matching still swaps preserved envelopes outside prepend-only edits.
Matching system messages strictly from the tail fixes the prepended-system case, but it still misattributes envelopes when a system message is appended, removed from the end, or inserted in the middle. For example, original
[A]→ modified[A, X]makesAlook new and patchesXontoA's preserved envelope in both the raw JSON rewrite path and the in-memory request path. This needs a stable alignment step (for example LCS, or equivalent leading+trailing matching) before treating the remainder as insertions.Also applies to: 531-538, 573-579
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/guardrails/provider.go` around lines 234 - 245, The current tail-only matching in tailMatchedSystemOffsets misaligns system messages when messages are appended/inserted/removed; replace the simplistic tail-based alignment with a stable alignment step (e.g., compute an LCS or do combined leading+trailing matching) between systemOriginals and the list of modified system messages before you compute systemMatchStart/originalSystemStart so that subsequent logic in the loop (references to systemMatchStart, originalSystemStart, nextSystem, and patchRawChatMessage) maps preserved envelopes to the correct original system messages; ensure the function tailMatchedSystemOffsets (or the call site that uses it) returns indices based on that stable alignment so insertions/deletions no longer cause envelope swaps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cmd/gomodel/docs/swagger.json`:
- Around line 535-976: The passthrough OpenAPI responses for the
/p/{provider}/{endpoint} operations are too narrow (all methods return 200 +
type:file), causing generated clients to mishandle valid upstream responses;
update the Swagger annotations in the passthrough handler (look for the handler
that registers /p/{provider}/{endpoint} in your *handler*.go) so each HTTP
method advertises method-appropriate success responses (e.g., POST/PUT: allow
200/201/202; DELETE/OPTIONS/HEAD: include 204 where appropriate; HEAD: do not
declare a response body schema) or declare a generic opaque response that
permits multiple success status codes, then regenerate the Swagger artifacts by
running the documented make swagger step.
In `@config/config_test.go`:
- Around line 271-304: Replace manual os.Setenv/_ = os.Unsetenv usage in the
subtests with t.Setenv to ensure automatic cleanup and consistency with other
tests; in the loop body that uses withTempDir and calls Load(), call
clearAllConfigEnvVars(t) at the start of each subtest to ensure isolation, and
remove the outer clearAllConfigEnvVars(t) invocation so cleanup is per-subtest.
Specifically update the subtest that writes config.yaml and sets
ENABLE_PROVIDER_PASSTHROUGH and NORMALIZE_PASSTHROUGH_V1_PREFIX to use
t.Setenv("ENABLE_PROVIDER_PASSTHROUGH", tt.envEnabled) and
t.Setenv("NORMALIZE_PASSTHROUGH_V1_PREFIX", tt.envNormalize) and add
clearAllConfigEnvVars(t) before those t.Setenv calls; leave the rest of the
assertions against Load(), result.Config.Server.EnableProviderPassthrough and
result.Config.Server.NormalizePassthroughV1Prefix unchanged.
- Around line 310-314: Replace the manual os.Setenv and manual cleanup in the
withTempDir test helper with t.Setenv to ensure consistent test environment
handling; specifically, in the closure passed to withTempDir where
PASSTHROUGH_ENABLED_FROM_YAML is set, call
t.Setenv("PASSTHROUGH_ENABLED_FROM_YAML", "false") instead of os.Setenv and
remove the t.Cleanup Unsetenv logic so the test framework will restore the env
automatically.
In `@internal/core/batch_semantic.go`:
- Around line 155-166: Replace the manual loop that checks whether operation
exists in operations with the standard library helper slices.Contains to
simplify the code: import "slices" and change the logic around variables
operations and operation so it becomes if len(operations) > 0 &&
!slices.Contains(operations, operation) { return nil, false, nil }, preserving
the exact behavior and return values.
In `@internal/core/ingress.go`:
- Around line 3-31: The struct IngressFrame is documented as immutable but
exposes mutable exported fields (e.g., RawBody, RouteParams, QueryParams,
Headers, TraceMetadata) that callers can modify; either make the fields
unexported and add accessor methods that return defensive copies (e.g., methods
RawBody() []byte, RouteParams() map[string]string, QueryParams()
map[string][]string, Headers() map[string][]string, TraceMetadata()
map[string]string) to guarantee immutability, or change the type comment to
remove the immutability claim—pick one approach and apply consistently to the
IngressFrame definition and any code constructing it (constructors should
populate unexported fields and callers must use accessors).
In `@internal/core/message_json.go`:
- Around line 19-27: Update the Go doc comments for the exported JSON methods
(e.g., Message.UnmarshalJSON, Message.MarshalJSON and any other exported
Marshal/UnmarshalJSON methods in this file) to state that unknown JSON members
are preserved in the ExtraFields map (type ExtraFields /
map[string]json.RawMessage) and that these methods continue to perform content
validation and null handling; apply the same doc update to the other exported
JSON-method comments indicated (the blocks at the other ranges) so the public
docs reflect preservation of unknown fields.
In `@internal/core/passthrough.go`:
- Around line 9-13: The PassthroughRequest struct's Headers field currently
flattens repeated headers as map[string]string; change Headers to preserve
repeated values by using map[string][]string (or http.Header) on the request
side (PassthroughRequest.Headers) and update all codepaths that construct, read,
or forward PassthroughRequest (e.g., any function that creates a
PassthroughRequest, the passthrough handler that reads Headers, and any
forwarding logic that copies headers to an http.Request) to use the new
slice-based type and to avoid collapsing or joining header values; ensure
callers convert from existing representations explicitly (joining/splitting only
when necessary) and run tests to update any assertions expecting single-string
header values.
In `@internal/guardrails/provider.go`:
- Around line 581-589: The code in applyGuardedMessageToOriginal preserves the
original's tool metadata via cloneChatMessageEnvelope but only overwrites it
when modified.ToolCalls or modified.ToolCallID are non-empty, so
guardrail-driven removals are ignored; change applyGuardedMessageToOriginal to
always assign the guarded values (set preserved.ToolCalls =
cloneToolCalls(modified.ToolCalls) even when empty and set preserved.ToolCallID
= modified.ToolCallID unconditionally) so empty/cleared tool_calls or
tool_call_id from the guarded Message replace the originals (use cloneToolCalls
and mirror the behavior of patchRawChatMessage).
In `@internal/providers/file_adapter_openai_compat.go`:
- Around line 18-19: Replace the incorrect use of core.NewInvalidRequestError in
the client == nil check inside internal/providers/file_adapter_openai_compat.go:
instead of returning core.NewInvalidRequestError("provider client is not
configured", nil) when the provider client is missing, return a gateway-level
error using the core.NewGatewayError constructor with the provider_error
category (e.g., the GatewayError provider_error constant) and the same
descriptive message, preserving the nil/underlying error field; update the
return to use core.NewGatewayError(..., "provider client is not configured",
nil) so the error is classified as a provider/server-side GatewayError rather
than an invalid_request_error.
- Around line 146-151: Update the exported Go doc comments for
GetOpenAICompatibleFile, DeleteOpenAICompatibleFile and
GetOpenAICompatibleFileContent to state that incoming IDs are normalized via
validatedOpenAICompatibleFileID (IDs are trimmed/normalized before the request)
and that the returned response objects have synthesized/normalized ID and Object
fields added when missing; mention the exact functions (GetOpenAICompatibleFile,
DeleteOpenAICompatibleFile, GetOpenAICompatibleFileContent) so readers know the
public contract includes ID normalization and response field synthesis.
In `@internal/providers/responses_adapter.go`:
- Around line 266-270: convertResponsesInputItems and mergeAssistantMessage
currently allow assistant messages to be merged while discarding ExtraFields;
change the logic to preserve metadata by preventing merges when either message
has non-nil/non-empty ExtraFields or implement an explicit merge policy for
ExtraFields. Specifically, update mergeAssistantMessage (or add a pre-check in
convertResponsesInputItems) to detect item.ExtraFields and other.ExtraFields
(use core.CloneRawJSONMap semantics if merging is chosen) and if either contains
data, do not combine content/tool calls — instead return separate messages or
merge ExtraFields explicitly according to a defined policy so top-level metadata
is not silently lost.
In `@internal/providers/router.go`:
- Around line 68-76: In resolveProviderType ensure that when r.checkReady()
returns ErrRegistryNotInitialized you return a typed *core.GatewayError (not the
raw error) so handleError() will serialize it; modify Router.resolveProviderType
to detect err == ErrRegistryNotInitialized and return a constructed
core.GatewayError (using the appropriate category, e.g., provider_error or
not_found_error, and the original error message) instead of returning the raw
err from checkReady(); reference resolveProviderType, checkReady,
ErrRegistryNotInitialized, core.GatewayError and handleError when making the
change.
In `@internal/server/handlers.go`:
- Around line 406-412: Replace the raw request context with a context wrapped by
core.WithRequestID before calling passthroughProvider.Passthrough so the
provider gets the propagated request-id; specifically, instead of ctx :=
c.Request().Context(), call core.WithRequestID(c.Request().Context(),
c.Request().Header.Get("X-Request-ID")) and pass that ctx into
passthroughProvider.Passthrough; also ensure buildPassthroughHeaders adds or
preserves the "X-Request-ID" header (using core.RequestIDFromContext(ctx) if the
incoming header was empty) so core.PassthroughRequest.Headers carries the
request id downstream.
---
Outside diff comments:
In `@internal/llmclient/client.go`:
- Around line 154-163: The DoRaw retry loop must be disabled when
Request.RawBodyReader is set because that reader is one-shot; update the retry
logic in the DoRaw function (and any other retry sites referenced around the
existing attempts at lines similar to 299-310 and 582-584) to detect if
req.RawBodyReader != nil and in that case avoid multiple attempts (e.g., set
maxAttempts=1 or skip the retry branch) so the request is only sent once; also
ensure the code treats RawBodyReader as mutually exclusive with Body/RawBody as
already documented in the Request struct to avoid ambiguous behavior.
In `@internal/server/http.go`:
- Around line 87-95: The metrics conflict check should also block any configured
metricsPath that targets passthrough routes under /p/ to avoid auth bypass:
update the guard that currently checks metricsPath against "/v1" and
strings.HasPrefix(metricsPath, "/v1/") to also check for metricsPath == "/p" or
strings.HasPrefix(metricsPath, "/p/"), log the same warning (include
cfg.MetricsEndpoint and normalized metricsPath) and set metricsPath = "/metrics"
before appending to authSkipPaths; make the identical change to the other
metrics-guard instance that uses the same variables (metricsPath,
cfg.MetricsEndpoint, authSkipPaths) in this file.
---
Duplicate comments:
In `@internal/core/chat_json_test.go`:
- Around line 72-83: The test currently only asserts presence of x_message_meta,
x_function_meta, and related extra-field objects; update the assertions in Test
(around req.Messages[0], req.Messages[0].ToolCalls, and
req.Messages[0].ToolCalls[0].Function) to validate specific nested keys/values
(e.g., expected fields inside ExtraFields["x_message_meta"],
ExtraFields["x_tool_call"], and Function.ExtraFields["x_function_meta"] and any
x_trace entries) both before and after json.Marshal/unmarshal, ensuring the
nested values equal the expected strings/structures instead of merely non-nil
objects so empty-object regressions are caught.
In `@internal/core/embeddings_json_test.go`:
- Around line 67-72: The test currently only asserts that decoded["x_trace"] is
an object; update the test to also assert the nested id field survives the
round-trip by casting decoded["x_trace"] to a map (e.g., xTraceMap :=
decoded["x_trace"].(map[string]any)) and then checking xTraceMap["id"] ==
<original expected id value> (or t.Fatalf on mismatch). Ensure you reference
decoded and the nested "id" key so the test fails if the object is empty but
missing its expected content.
In `@internal/guardrails/provider.go`:
- Around line 234-245: The current tail-only matching in
tailMatchedSystemOffsets misaligns system messages when messages are
appended/inserted/removed; replace the simplistic tail-based alignment with a
stable alignment step (e.g., compute an LCS or do combined leading+trailing
matching) between systemOriginals and the list of modified system messages
before you compute systemMatchStart/originalSystemStart so that subsequent logic
in the loop (references to systemMatchStart, originalSystemStart, nextSystem,
and patchRawChatMessage) maps preserved envelopes to the correct original system
messages; ensure the function tailMatchedSystemOffsets (or the call site that
uses it) returns indices based on that stable alignment so insertions/deletions
no longer cause envelope swaps.
In `@internal/providers/file_adapter_openai_compat_test.go`:
- Around line 20-57: The test must assert HTTP method and exercise the DoRaw
path used by GetOpenAICompatibleFileContent so normalization is fully covered:
update TestValidatedOpenAICompatibleFileID (or add a sibling test) to have the
test server verify r.Method for the JSON /files/{id} request (e.g., assert GET
or DELETE as appropriate in the handler), and add a case that calls
GetOpenAICompatibleFileContent with an id containing surrounding whitespace so
the handler for /files/{id}/content (the DoRaw path) asserts the request method
and returns the normalized id in the response body; then assert the returned id
equals the trimmed/normalized value and keep the existing GatewayError
assertions for invalid (whitespace-only) ids. Ensure you reference
validatedOpenAICompatibleFileID and GetOpenAICompatibleFileContent/DoRaw when
locating where to add these checks.
In `@internal/server/handlers.go`:
- Around line 224-236: The buildPassthroughHeaders function currently forwards
headers named in the incoming Connection header; parse the src "Connection"
header values, canonicalize each token (use http.CanonicalHeaderKey and
strings.TrimSpace) and treat those tokens as hop-by-hop names to skip just like
skipPassthroughRequestHeader does; update buildPassthroughHeaders (and the
similar logic around the other block that covers lines 259-279) to ignore any
header whose canonical name appears in the parsed Connection list so no
Connection-nominated headers (e.g. X-Debug) are copied into dst.
In `@internal/server/ingress_test.go`:
- Around line 112-143: The test TestIngressCapture_GeneratesRequestIDWhenMissing
should also assert that the generated RequestID is written back to the HTTP
header and stored in the request context so downstream code sees the same ID:
after calling handler(c) fetch the X-Request-ID header from
rec.Result().Header.Get("X-Request-ID") and assert it equals
capturedFrame.RequestID, and also retrieve the value from context via
core.GetRequestID(c.Request().Context()) or the equivalent context accessor used
by core.WithRequestID(...) and assert that equals capturedFrame.RequestID;
ensure you reference the existing middleware IngressCapture, capturedFrame from
core.GetIngressFrame, and the X-Request-ID header in these new assertions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7c7ad188-9d0e-4378-83e6-9794695e2808
📒 Files selected for processing (40)
cmd/gomodel/docs/docs.gocmd/gomodel/docs/swagger.jsonconfig/config_test.gointernal/app/app.gointernal/auditlog/reader_mongodb.gointernal/core/batch_json_test.gointernal/core/batch_semantic.gointernal/core/chat_json_test.gointernal/core/embeddings_json_test.gointernal/core/endpoints.gointernal/core/endpoints_test.gointernal/core/files.gointernal/core/ingress.gointernal/core/message_json.gointernal/core/passthrough.gointernal/core/responses.gointernal/core/responses_json.gointernal/core/responses_json_test.gointernal/core/semantic_canonical.gointernal/guardrails/provider.gointernal/guardrails/provider_test.gointernal/llmclient/client.gointernal/llmclient/client_test.gointernal/providers/anthropic/anthropic.gointernal/providers/anthropic/anthropic_test.gointernal/providers/file_adapter_openai_compat.gointernal/providers/file_adapter_openai_compat_test.gointernal/providers/openai/openai.gointernal/providers/openai/openai_test.gointernal/providers/registry.gointernal/providers/responses_adapter.gointernal/providers/responses_adapter_test.gointernal/providers/router.gointernal/providers/router_test.gointernal/server/handlers.gointernal/server/handlers_test.gointernal/server/http.gointernal/server/http_test.gointernal/server/ingress.gointernal/server/ingress_test.go
| if client == nil { | ||
| return "", core.NewInvalidRequestError("provider client is not configured", nil) |
There was a problem hiding this comment.
Don’t classify a missing provider client as invalid_request_error.
client == nil means this adapter was invoked without provider configuration, not that the caller supplied a bad file ID. Returning core.NewInvalidRequestError here will surface a server-side failure as a 4xx on the new GET/DELETE/content paths. Use a provider/server-side GatewayError category instead.
As per coding guidelines, **/*.go: All errors returned to clients must use core.GatewayError with typed categories (provider_error, rate_limit_error, invalid_request_error, authentication_error, not_found_error).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/providers/file_adapter_openai_compat.go` around lines 18 - 19,
Replace the incorrect use of core.NewInvalidRequestError in the client == nil
check inside internal/providers/file_adapter_openai_compat.go: instead of
returning core.NewInvalidRequestError("provider client is not configured", nil)
when the provider client is missing, return a gateway-level error using the
core.NewGatewayError constructor with the provider_error category (e.g., the
GatewayError provider_error constant) and the same descriptive message,
preserving the nil/underlying error field; update the return to use
core.NewGatewayError(..., "provider client is not configured", nil) so the error
is classified as a provider/server-side GatewayError rather than an
invalid_request_error.
There was a problem hiding this comment.
Actionable comments posted: 9
♻️ Duplicate comments (2)
cmd/gomodel/docs/swagger.json (1)
586-590:⚠️ Potential issue | 🟠 MajorRemove body schemas from passthrough
HEADand204responses.
HEADnever returns a response body, and204 No Contentcannot define one either. This spec still advertisestype: fileforheadsuccess cases andtype: stringfor every204, so generated clients will treat impossible payloads as valid. Please fix the source Swagger annotations for/p/{provider}/{endpoint}and regenerate both docs artifacts.In Swagger/OpenAPI 2.0, can a HEAD response include a response body schema, and can a 204 No Content response include a response body schema?Based on learnings: Applies to **/handler.go : Keep endpoint documentation concise and accurate in code comments (summary, params, success/error responses), and if any API endpoint is added, removed, or changed (path, method, params, request/response schema), update Swagger annotations and run
make swaggerAlso applies to: 667-671, 748-752, 829-833, 910-914, 972-995, 1072-1076
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmd/gomodel/docs/swagger.json` around lines 586 - 590, Update the Swagger annotations in the handler source (files matching *handler*.go) for the /p/{provider}/{endpoint} path and the other listed occurrences (around the noted blocks) to remove any "schema" or "type" entries for HEAD success responses and 204 No Content responses (i.e., do not declare a response body for HEAD or 204), then regenerate the OpenAPI JSON by running the project's swagger generation (e.g., make swagger) so cmd/gomodel/docs/swagger.json no longer advertises impossible payloads; look for and edit the response annotations in the relevant handler functions that document the path/method success responses and remove the body schema lines before regenerating.internal/core/ingress.go (1)
3-32: 🛠️ Refactor suggestion | 🟠 Major
IngressFramestill isn't a stable snapshot.
RawBodyand the exported map fields can be mutated after capture, so later semantic/audit stages can observe a different request than ingress did. If this type is meant to represent the original inbound frame, it needs defensive copying/private fields rather than a read-only convention.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/core/ingress.go` around lines 3 - 32, IngressFrame currently exposes mutable fields (RawBody, RouteParams, QueryParams, Headers, TraceMetadata) so callers can mutate the captured frame; change IngressFrame to keep those fields unexported (e.g., rawBody []byte, routeParams map[string]string, queryParams map[string][]string, headers map[string][]string, traceMetadata map[string]string) and add a constructor like NewIngressFrame(...) that defensively copies incoming maps/slices/bytes into the private fields; expose only accessor methods GetRawBody(), GetRouteParams(), GetQueryParams(), GetHeaders(), GetTraceMetadata() that return deep copies (or read-only views) so consumers cannot mutate the internal state, while keeping other exported fields (Method, Path, ContentType, RawBodyTooLarge, RequestID) as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@config/config_test.go`:
- Around line 301-325: The test TestLoad_PassthroughFlags_YAMLExpansion is
nondeterministic because it relies on ${PASSTHROUGH_NORMALIZE_FROM_YAML:-false}
but never ensures PASSTHROUGH_NORMALIZE_FROM_YAML is unset; make the
YAML-expansion hermetic by explicitly unsetting that env var before writing the
YAML (e.g. call os.Unsetenv("PASSTHROUGH_NORMALIZE_FROM_YAML") or ensure
clearAllConfigEnvVars(t) clears that specific name) so the default branch is
exercised reliably when Load() is called.
In `@internal/llmclient/client_test.go`:
- Around line 436-606: Update the passthrough tests to assert replay-safety
semantics more tightly: in
TestClient_DoPassthrough_RetriesWhenIdempotencyKeyPresent, modify the test
server handler (the httptest handler inside that function) to validate that the
incoming request contains the "Idempotency-Key" header and fail the test if it's
missing (this proves the key is forwarded upstream); add a new test (e.g.,
TestClient_DoPassthrough_NonReplayableRawBody_DoesNotRetry) that supplies a
non-replayable RawBody reader (a reader that errors on a second read or is a
one-shot pipe) in the Request passed to Client.DoPassthrough and asserts the
server saw only one attempt and the client did not retry, referencing
Request.RawBody and DoPassthrough to locate where to construct the one-shot
reader and check retry behavior.
In `@internal/providers/anthropic/anthropic_test.go`:
- Around line 3988-4047: The passthrough test needs to assert X-Request-ID
generation/propagation: update TestPassthrough to capture the incoming
"X-Request-ID" header from the test server (add a gotRequestID variable in the
httptest handler) and remove any X-Request-ID from the request Headers in
core.PassthroughRequest so the provider must generate it; after calling
provider.Passthrough verify gotRequestID is non-empty and a valid UUID (or at
least matches UUID format), and also add an additional subcase where you set a
known X-Request-ID in the request headers and assert the same value is forwarded
(to cover both generation and propagation paths). Ensure you modify only
TestPassthrough and its request headers; use NewWithHTTPClient,
provider.Passthrough, and core.PassthroughRequest to locate the code to change.
In `@internal/providers/file_adapter_openai_compat_test.go`:
- Around line 184-239: Add a regression sub-test to
TestGetOpenAICompatibleFileContent that simulates a non-2xx response from the
content endpoint (e.g., have the httptest server return a 500 status and
optional error body) for a valid trimmed id, call
GetOpenAICompatibleFileContent(ctx, client, id), and assert it returns an error
that matches errors.As(err, &core.GatewayError) and verifies the GatewayError
Type/fields as the other adapter tests do; reference
GetOpenAICompatibleFileContent and the existing test scaffold (httptest server,
newOpenAICompatibleTestClient) to add the 4xx/5xx case alongside the existing
success and input-validation cases.
In `@internal/providers/responses_adapter.go`:
- Around line 251-256: The typed `function_call_output` branch returns
item.Output as-is which causes structured typed outputs to be sent as JSON
objects instead of the expected string; update the code that builds the
core.Message (the return that sets Role "tool" and ToolCallID callID) to
JSON-stringify item.Output the same way the map[string]interface{} branch does
(i.e., serialize item.Output to a string and use that string for Content),
preserving ExtraFields via core.CloneRawJSONMap(item.ExtraFields).
In `@internal/server/handlers_test.go`:
- Around line 2015-2023: The test's JSON payload (reqBody) hard-codes
"provider":"openai", causing a false positive for the selector-extraction
behavior; remove the explicit "provider" key from the batch item body in the
selector-extraction test (and the similar cases around the other occurrences) so
the handler must derive provider from the URL, or alternatively update the test
assertion to check the provider value produced by URL normalization/captured
batch state instead of the hard-coded field; update the test expectations in the
same test function that constructs reqBody and any assertions that currently
rely on the hard-coded provider.
- Around line 3045-3279: Add two assertions/tests: (1) in the passthrough
happy-path (e.g., TestProviderPassthrough_OpenAI or a new
TestProviderPassthrough_AutoGeneratesRequestID) ensure when the incoming request
lacks X-Request-ID the handler generates one and that
provider.lastPassthroughReq.Headers.Get("X-Request-ID") is non-empty (and
optionally UUID-format) and that the response includes the same X-Request-ID
header; (2) add a failing-upstream test (or extend
TestProviderPassthrough_RejectsUnsupportedProvider) that triggers a 4xx/upstream
error from mockProvider and assert the HTTP response body is the typed JSON
error produced by handleError() (i.e., contains structured fields like
code/message) and that errors.As(GatewayError) path is exercised by verifying
the response Content-Type is application/json and X-Request-ID is present; focus
changes around handler.ProviderPassthrough, handleError, GatewayError usage and
mockProvider.passthroughResponse to simulate the error.
In `@internal/server/handlers.go`:
- Around line 179-186: The function isSupportedPassthroughProvider currently
hardcodes "openai" and "anthropic" which prevents extending passthrough
providers; update it to consult a configurable source (e.g., a slice/set in a
config struct or an environment-backed map) instead of the switch, initialize
that list during startup (e.g., in your config loader) and change
isSupportedPassthroughProvider to trim and check membership against that
runtime-configured list; ensure the symbol names involved are the existing
isSupportedPassthroughProvider function and the app configuration (e.g.,
Config.SupportedPassthroughProviders or a package-level
supportedPassthroughProviders variable) so future providers can be added without
code changes.
---
Duplicate comments:
In `@cmd/gomodel/docs/swagger.json`:
- Around line 586-590: Update the Swagger annotations in the handler source
(files matching *handler*.go) for the /p/{provider}/{endpoint} path and the
other listed occurrences (around the noted blocks) to remove any "schema" or
"type" entries for HEAD success responses and 204 No Content responses (i.e., do
not declare a response body for HEAD or 204), then regenerate the OpenAPI JSON
by running the project's swagger generation (e.g., make swagger) so
cmd/gomodel/docs/swagger.json no longer advertises impossible payloads; look for
and edit the response annotations in the relevant handler functions that
document the path/method success responses and remove the body schema lines
before regenerating.
In `@internal/core/ingress.go`:
- Around line 3-32: IngressFrame currently exposes mutable fields (RawBody,
RouteParams, QueryParams, Headers, TraceMetadata) so callers can mutate the
captured frame; change IngressFrame to keep those fields unexported (e.g.,
rawBody []byte, routeParams map[string]string, queryParams map[string][]string,
headers map[string][]string, traceMetadata map[string]string) and add a
constructor like NewIngressFrame(...) that defensively copies incoming
maps/slices/bytes into the private fields; expose only accessor methods
GetRawBody(), GetRouteParams(), GetQueryParams(), GetHeaders(),
GetTraceMetadata() that return deep copies (or read-only views) so consumers
cannot mutate the internal state, while keeping other exported fields (Method,
Path, ContentType, RawBodyTooLarge, RequestID) as needed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7ef655d1-74d0-4630-baf8-156aa299509c
📒 Files selected for processing (27)
cmd/gomodel/docs/docs.gocmd/gomodel/docs/swagger.jsonconfig/config_test.gointernal/core/batch_semantic.gointernal/core/chat_json_test.gointernal/core/embeddings_json_test.gointernal/core/ingress.gointernal/core/message_json.gointernal/core/passthrough.gointernal/guardrails/provider.gointernal/guardrails/provider_test.gointernal/llmclient/client.gointernal/llmclient/client_test.gointernal/providers/anthropic/anthropic_test.gointernal/providers/file_adapter_openai_compat.gointernal/providers/file_adapter_openai_compat_test.gointernal/providers/openai/openai_test.gointernal/providers/responses_adapter.gointernal/providers/responses_adapter_test.gointernal/providers/router.gointernal/providers/router_test.gointernal/server/handlers.gointernal/server/handlers_test.gointernal/server/http.gointernal/server/http_test.gointernal/server/ingress.gointernal/server/ingress_test.go
| func TestLoad_PassthroughFlags_YAMLExpansion(t *testing.T) { | ||
| withTempDir(t, func(dir string) { | ||
| clearAllConfigEnvVars(t) | ||
| t.Setenv("PASSTHROUGH_ENABLED_FROM_YAML", "false") | ||
|
|
||
| yaml := ` | ||
| server: | ||
| enable_provider_passthrough: ${PASSTHROUGH_ENABLED_FROM_YAML} | ||
| normalize_passthrough_v1_prefix: ${PASSTHROUGH_NORMALIZE_FROM_YAML:-false} | ||
| ` | ||
| if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil { | ||
| t.Fatalf("Failed to write config.yaml: %v", err) | ||
| } | ||
|
|
||
| result, err := Load() | ||
| if err != nil { | ||
| t.Fatalf("Load() failed: %v", err) | ||
| } | ||
| if result.Config.Server.EnableProviderPassthrough { | ||
| t.Fatal("expected YAML ${VAR} expansion to set EnableProviderPassthrough=false") | ||
| } | ||
| if result.Config.Server.NormalizePassthroughV1Prefix { | ||
| t.Fatal("expected YAML ${VAR:-default} expansion to set NormalizePassthroughV1Prefix=false") | ||
| } | ||
| }) |
There was a problem hiding this comment.
Make the YAML-expansion case hermetic.
Line 309 relies on ${PASSTHROUGH_NORMALIZE_FROM_YAML:-false}, but this test never clears or sets PASSTHROUGH_NORMALIZE_FROM_YAML. If that variable is present in the runner environment, the case stops exercising the default branch and becomes nondeterministic.
♻️ Suggested fix
func TestLoad_PassthroughFlags_YAMLExpansion(t *testing.T) {
withTempDir(t, func(dir string) {
clearAllConfigEnvVars(t)
t.Setenv("PASSTHROUGH_ENABLED_FROM_YAML", "false")
+ t.Setenv("PASSTHROUGH_NORMALIZE_FROM_YAML", "")
yaml := `
server:
enable_provider_passthrough: ${PASSTHROUGH_ENABLED_FROM_YAML}
normalize_passthrough_v1_prefix: ${PASSTHROUGH_NORMALIZE_FROM_YAML:-false}
`As per coding guidelines, config/*.go: Config loading follows cascade: code defaults → config/config.yaml (supports ${VAR} and ${VAR:-default} expansion) → environment variables (always win), and **/*_test.go: Add or update tests for any behavior change.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@config/config_test.go` around lines 301 - 325, The test
TestLoad_PassthroughFlags_YAMLExpansion is nondeterministic because it relies on
${PASSTHROUGH_NORMALIZE_FROM_YAML:-false} but never ensures
PASSTHROUGH_NORMALIZE_FROM_YAML is unset; make the YAML-expansion hermetic by
explicitly unsetting that env var before writing the YAML (e.g. call
os.Unsetenv("PASSTHROUGH_NORMALIZE_FROM_YAML") or ensure
clearAllConfigEnvVars(t) clears that specific name) so the default branch is
exercised reliably when Load() is called.
| func TestClient_DoPassthrough_WithRetries(t *testing.T) { | ||
| var attempts int32 | ||
|
|
||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| count := atomic.AddInt32(&attempts, 1) | ||
| if count < 3 { | ||
| w.WriteHeader(http.StatusTooManyRequests) | ||
| _, _ = w.Write([]byte(`{"error":{"message":"rate limited"}}`)) | ||
| return | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{"ok":true}`)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| config := DefaultConfig("test", server.URL) | ||
| config.Retry.MaxRetries = 3 | ||
| config.Retry.InitialBackoff = 10 * time.Millisecond | ||
| config.Retry.JitterFactor = 0 | ||
| client := New(config, nil) | ||
|
|
||
| resp, err := client.DoPassthrough(context.Background(), Request{ | ||
| Method: http.MethodGet, | ||
| Endpoint: "/test", | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", resp.StatusCode) | ||
| } | ||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| t.Fatalf("failed to read body: %v", err) | ||
| } | ||
| if got := string(body); got != `{"ok":true}` { | ||
| t.Fatalf("body = %q, want success response", got) | ||
| } | ||
| if got := atomic.LoadInt32(&attempts); got != 3 { | ||
| t.Fatalf("attempts = %d, want 3", got) | ||
| } | ||
| } | ||
|
|
||
| func TestClient_DoPassthrough_ReturnsLastRetryableResponseAfterRetries(t *testing.T) { | ||
| var attempts int32 | ||
|
|
||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| count := atomic.AddInt32(&attempts, 1) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusServiceUnavailable) | ||
| _, _ = w.Write([]byte(`{"attempt":` + strconv.FormatInt(int64(count), 10) + `}`)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| config := DefaultConfig("test", server.URL) | ||
| config.Retry.MaxRetries = 2 | ||
| config.Retry.InitialBackoff = 10 * time.Millisecond | ||
| config.Retry.MaxBackoff = 10 * time.Millisecond | ||
| config.Retry.BackoffFactor = 1 | ||
| config.Retry.JitterFactor = 0 | ||
| client := New(config, nil) | ||
|
|
||
| resp, err := client.DoPassthrough(context.Background(), Request{ | ||
| Method: http.MethodGet, | ||
| Endpoint: "/test", | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusServiceUnavailable { | ||
| t.Fatalf("status = %d, want 503", resp.StatusCode) | ||
| } | ||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| t.Fatalf("failed to read body: %v", err) | ||
| } | ||
| if got := string(body); got != `{"attempt":3}` { | ||
| t.Fatalf("body = %q, want final retry response", got) | ||
| } | ||
| if got := atomic.LoadInt32(&attempts); got != 3 { | ||
| t.Fatalf("attempts = %d, want 3", got) | ||
| } | ||
| } | ||
|
|
||
| func TestClient_DoPassthrough_DoesNotRetryNonReplaySafeMethod(t *testing.T) { | ||
| var attempts int32 | ||
|
|
||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| atomic.AddInt32(&attempts, 1) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusTooManyRequests) | ||
| _, _ = w.Write([]byte(`{"error":"rate limited"}`)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| config := DefaultConfig("test", server.URL) | ||
| config.Retry.MaxRetries = 3 | ||
| config.Retry.InitialBackoff = 10 * time.Millisecond | ||
| config.Retry.MaxBackoff = 10 * time.Millisecond | ||
| config.Retry.BackoffFactor = 1 | ||
| config.Retry.JitterFactor = 0 | ||
| client := New(config, nil) | ||
|
|
||
| resp, err := client.DoPassthrough(context.Background(), Request{ | ||
| Method: http.MethodPost, | ||
| Endpoint: "/test", | ||
| RawBody: []byte(`{"hello":"world"}`), | ||
| Headers: http.Header{"Content-Type": {"application/json"}}, | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusTooManyRequests { | ||
| t.Fatalf("status = %d, want 429", resp.StatusCode) | ||
| } | ||
| if got := atomic.LoadInt32(&attempts); got != 1 { | ||
| t.Fatalf("attempts = %d, want 1", got) | ||
| } | ||
| } | ||
|
|
||
| func TestClient_DoPassthrough_RetriesWhenIdempotencyKeyPresent(t *testing.T) { | ||
| var attempts int32 | ||
|
|
||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| count := atomic.AddInt32(&attempts, 1) | ||
| if count < 3 { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusServiceUnavailable) | ||
| _, _ = w.Write([]byte(`{"attempt":` + strconv.FormatInt(int64(count), 10) + `}`)) | ||
| return | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{"ok":true}`)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| config := DefaultConfig("test", server.URL) | ||
| config.Retry.MaxRetries = 3 | ||
| config.Retry.InitialBackoff = 10 * time.Millisecond | ||
| config.Retry.MaxBackoff = 10 * time.Millisecond | ||
| config.Retry.BackoffFactor = 1 | ||
| config.Retry.JitterFactor = 0 | ||
| client := New(config, nil) | ||
|
|
||
| resp, err := client.DoPassthrough(context.Background(), Request{ | ||
| Method: http.MethodPost, | ||
| Endpoint: "/test", | ||
| RawBody: []byte(`{"hello":"world"}`), | ||
| Headers: http.Header{ | ||
| "Content-Type": {"application/json"}, | ||
| "Idempotency-Key": {"req-123"}, | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", resp.StatusCode) | ||
| } | ||
| if got := atomic.LoadInt32(&attempts); got != 3 { | ||
| t.Fatalf("attempts = %d, want 3", got) | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Tighten the passthrough replay-safety coverage.
These cases prove the retry counts, but they still leave two safety regressions undetected: the idempotency-key path never checks that Idempotency-Key is actually forwarded upstream, and there is no passthrough-specific RawBodyReader case to prove non-replayable readers stay single-attempt. Both failures would still pass this suite.
As per coding guidelines, **/*_test.go: Add or update tests for any behavior change.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/llmclient/client_test.go` around lines 436 - 606, Update the
passthrough tests to assert replay-safety semantics more tightly: in
TestClient_DoPassthrough_RetriesWhenIdempotencyKeyPresent, modify the test
server handler (the httptest handler inside that function) to validate that the
incoming request contains the "Idempotency-Key" header and fail the test if it's
missing (this proves the key is forwarded upstream); add a new test (e.g.,
TestClient_DoPassthrough_NonReplayableRawBody_DoesNotRetry) that supplies a
non-replayable RawBody reader (a reader that errors on a second read or is a
one-shot pipe) in the Request passed to Client.DoPassthrough and asserts the
server saw only one attempt and the client did not retry, referencing
Request.RawBody and DoPassthrough to locate where to construct the one-shot
reader and check retry behavior.
| func TestConvertDecodedBatchItemToAnthropic_RejectsStreaming(t *testing.T) { | ||
| decoded := &core.DecodedBatchItemRequest{ | ||
| Endpoint: "/v1/chat/completions", | ||
| Operation: "chat_completions", | ||
| Request: &core.ChatRequest{ | ||
| Model: "claude-sonnet-4-5-20250929", | ||
| Stream: true, | ||
| Messages: []core.Message{ | ||
| { | ||
| Role: "user", | ||
| Content: "Hello", | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| _, err := convertDecodedBatchItemToAnthropic(decoded) | ||
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| if !strings.Contains(err.Error(), "streaming is not supported for native batch") { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestConvertDecodedBatchItemToAnthropic_RejectsEmbeddings(t *testing.T) { | ||
| decoded := &core.DecodedBatchItemRequest{ | ||
| Endpoint: "/v1/embeddings", | ||
| Operation: "embeddings", | ||
| Request: &core.EmbeddingRequest{ | ||
| Model: "text-embedding-3-small", | ||
| Input: "Hello", | ||
| }, | ||
| } | ||
|
|
||
| _, err := convertDecodedBatchItemToAnthropic(decoded) | ||
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| if !strings.Contains(err.Error(), "anthropic does not support native embedding batches") { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| } |
There was a problem hiding this comment.
Assert GatewayError metadata in these rejection-path tests.
Both tests currently pass on a plain error as long as the message substring matches. That would miss a regression in the client-facing error contract for invalid native batch requests.
Proposed test tightening
func TestConvertDecodedBatchItemToAnthropic_RejectsStreaming(t *testing.T) {
decoded := &core.DecodedBatchItemRequest{
Endpoint: "/v1/chat/completions",
Operation: "chat_completions",
@@
_, err := convertDecodedBatchItemToAnthropic(decoded)
if err == nil {
t.Fatal("expected error, got nil")
}
- if !strings.Contains(err.Error(), "streaming is not supported for native batch") {
- t.Fatalf("unexpected error: %v", err)
- }
+ var gatewayErr *core.GatewayError
+ if !errors.As(err, &gatewayErr) {
+ t.Fatalf("error = %T, want *core.GatewayError", err)
+ }
+ if gatewayErr.Type != core.ErrorTypeInvalidRequest {
+ t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
+ }
+ if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
+ t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
+ }
+ if !strings.Contains(gatewayErr.Message, "streaming is not supported for native batch") {
+ t.Fatalf("unexpected error: %v", err)
+ }
}
func TestConvertDecodedBatchItemToAnthropic_RejectsEmbeddings(t *testing.T) {
decoded := &core.DecodedBatchItemRequest{
Endpoint: "/v1/embeddings",
@@
_, err := convertDecodedBatchItemToAnthropic(decoded)
if err == nil {
t.Fatal("expected error, got nil")
}
- if !strings.Contains(err.Error(), "anthropic does not support native embedding batches") {
- t.Fatalf("unexpected error: %v", err)
- }
+ var gatewayErr *core.GatewayError
+ if !errors.As(err, &gatewayErr) {
+ t.Fatalf("error = %T, want *core.GatewayError", err)
+ }
+ if gatewayErr.Type != core.ErrorTypeInvalidRequest {
+ t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
+ }
+ if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
+ t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
+ }
+ if !strings.Contains(gatewayErr.Message, "anthropic does not support native embedding batches") {
+ t.Fatalf("unexpected error: %v", err)
+ }
}As per coding guidelines **/*.go: All errors returned to clients must use core.GatewayError with typed categories (provider_error, rate_limit_error, invalid_request_error, authentication_error, not_found_error).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func TestConvertDecodedBatchItemToAnthropic_RejectsStreaming(t *testing.T) { | |
| decoded := &core.DecodedBatchItemRequest{ | |
| Endpoint: "/v1/chat/completions", | |
| Operation: "chat_completions", | |
| Request: &core.ChatRequest{ | |
| Model: "claude-sonnet-4-5-20250929", | |
| Stream: true, | |
| Messages: []core.Message{ | |
| { | |
| Role: "user", | |
| Content: "Hello", | |
| }, | |
| }, | |
| }, | |
| } | |
| _, err := convertDecodedBatchItemToAnthropic(decoded) | |
| if err == nil { | |
| t.Fatal("expected error, got nil") | |
| } | |
| if !strings.Contains(err.Error(), "streaming is not supported for native batch") { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| } | |
| func TestConvertDecodedBatchItemToAnthropic_RejectsEmbeddings(t *testing.T) { | |
| decoded := &core.DecodedBatchItemRequest{ | |
| Endpoint: "/v1/embeddings", | |
| Operation: "embeddings", | |
| Request: &core.EmbeddingRequest{ | |
| Model: "text-embedding-3-small", | |
| Input: "Hello", | |
| }, | |
| } | |
| _, err := convertDecodedBatchItemToAnthropic(decoded) | |
| if err == nil { | |
| t.Fatal("expected error, got nil") | |
| } | |
| if !strings.Contains(err.Error(), "anthropic does not support native embedding batches") { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| } | |
| func TestConvertDecodedBatchItemToAnthropic_RejectsStreaming(t *testing.T) { | |
| decoded := &core.DecodedBatchItemRequest{ | |
| Endpoint: "/v1/chat/completions", | |
| Operation: "chat_completions", | |
| Request: &core.ChatRequest{ | |
| Model: "claude-sonnet-4-5-20250929", | |
| Stream: true, | |
| Messages: []core.Message{ | |
| { | |
| Role: "user", | |
| Content: "Hello", | |
| }, | |
| }, | |
| }, | |
| } | |
| _, err := convertDecodedBatchItemToAnthropic(decoded) | |
| if err == nil { | |
| t.Fatal("expected error, got nil") | |
| } | |
| var gatewayErr *core.GatewayError | |
| if !errors.As(err, &gatewayErr) { | |
| t.Fatalf("error = %T, want *core.GatewayError", err) | |
| } | |
| if gatewayErr.Type != core.ErrorTypeInvalidRequest { | |
| t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type) | |
| } | |
| if gatewayErr.HTTPStatusCode() != http.StatusBadRequest { | |
| t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest) | |
| } | |
| if !strings.Contains(gatewayErr.Message, "streaming is not supported for native batch") { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| } | |
| func TestConvertDecodedBatchItemToAnthropic_RejectsEmbeddings(t *testing.T) { | |
| decoded := &core.DecodedBatchItemRequest{ | |
| Endpoint: "/v1/embeddings", | |
| Operation: "embeddings", | |
| Request: &core.EmbeddingRequest{ | |
| Model: "text-embedding-3-small", | |
| Input: "Hello", | |
| }, | |
| } | |
| _, err := convertDecodedBatchItemToAnthropic(decoded) | |
| if err == nil { | |
| t.Fatal("expected error, got nil") | |
| } | |
| var gatewayErr *core.GatewayError | |
| if !errors.As(err, &gatewayErr) { | |
| t.Fatalf("error = %T, want *core.GatewayError", err) | |
| } | |
| if gatewayErr.Type != core.ErrorTypeInvalidRequest { | |
| t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type) | |
| } | |
| if gatewayErr.HTTPStatusCode() != http.StatusBadRequest { | |
| t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest) | |
| } | |
| if !strings.Contains(gatewayErr.Message, "anthropic does not support native embedding batches") { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| } |
| func TestPassthrough(t *testing.T) { | ||
| var gotPath string | ||
| var gotAPIKey string | ||
| var gotVersion string | ||
| var gotBody string | ||
|
|
||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| gotPath = r.URL.RequestURI() | ||
| gotAPIKey = r.Header.Get("x-api-key") | ||
| gotVersion = r.Header.Get("anthropic-version") | ||
| body, _ := io.ReadAll(r.Body) | ||
| gotBody = string(body) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusBadRequest) | ||
| _, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) | ||
| provider.SetBaseURL(server.URL) | ||
|
|
||
| resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ | ||
| Method: http.MethodPost, | ||
| Endpoint: "messages", | ||
| Body: io.NopCloser(strings.NewReader(`{"model":"claude-sonnet-4-5"}`)), | ||
| Headers: http.Header{ | ||
| "Content-Type": {"application/json"}, | ||
| "anthropic-version": {"2024-10-22"}, | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| defer func() { | ||
| _ = resp.Body.Close() | ||
| }() | ||
|
|
||
| if gotPath != "/messages" { | ||
| t.Fatalf("path = %q, want /messages", gotPath) | ||
| } | ||
| if gotAPIKey != "test-api-key" { | ||
| t.Fatalf("x-api-key = %q", gotAPIKey) | ||
| } | ||
| if gotVersion != "2024-10-22" { | ||
| t.Fatalf("anthropic-version = %q", gotVersion) | ||
| } | ||
| if gotBody != `{"model":"claude-sonnet-4-5"}` { | ||
| t.Fatalf("body = %q", gotBody) | ||
| } | ||
| if resp.StatusCode != http.StatusBadRequest { | ||
| t.Fatalf("status = %d, want 400", resp.StatusCode) | ||
| } | ||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| t.Fatalf("failed to read response body: %v", err) | ||
| } | ||
| if string(body) != `{"error":{"message":"bad request"}}` { | ||
| t.Fatalf("response body = %q", string(body)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Cover X-Request-ID behavior in the new passthrough test.
This test validates forwarding, status, and body passthrough, but it leaves the required request ID generation/propagation path untested. A passthrough regression there would still go green.
Proposed test extension
func TestPassthrough(t *testing.T) {
var gotPath string
var gotAPIKey string
var gotVersion string
+ var gotRequestID string
var gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.RequestURI()
gotAPIKey = r.Header.Get("x-api-key")
gotVersion = r.Header.Get("anthropic-version")
+ gotRequestID = r.Header.Get("X-Request-ID")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
@@
if gotVersion != "2024-10-22" {
t.Fatalf("anthropic-version = %q", gotVersion)
}
+ if gotRequestID == "" {
+ t.Fatal("X-Request-ID should be generated and forwarded")
+ }
if gotBody != `{"model":"claude-sonnet-4-5"}` {
t.Fatalf("body = %q", gotBody)
}As per coding guidelines {internal/server/**/*.go,internal/providers/**/*.go}: Auto-generate X-Request-ID as UUID if not present in request and propagate through context to providers and audit logs, and **/*_test.go: Add or update tests for any behavior change.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func TestPassthrough(t *testing.T) { | |
| var gotPath string | |
| var gotAPIKey string | |
| var gotVersion string | |
| var gotBody string | |
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| gotPath = r.URL.RequestURI() | |
| gotAPIKey = r.Header.Get("x-api-key") | |
| gotVersion = r.Header.Get("anthropic-version") | |
| body, _ := io.ReadAll(r.Body) | |
| gotBody = string(body) | |
| w.Header().Set("Content-Type", "application/json") | |
| w.WriteHeader(http.StatusBadRequest) | |
| _, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`)) | |
| })) | |
| defer server.Close() | |
| provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) | |
| provider.SetBaseURL(server.URL) | |
| resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ | |
| Method: http.MethodPost, | |
| Endpoint: "messages", | |
| Body: io.NopCloser(strings.NewReader(`{"model":"claude-sonnet-4-5"}`)), | |
| Headers: http.Header{ | |
| "Content-Type": {"application/json"}, | |
| "anthropic-version": {"2024-10-22"}, | |
| }, | |
| }) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| defer func() { | |
| _ = resp.Body.Close() | |
| }() | |
| if gotPath != "/messages" { | |
| t.Fatalf("path = %q, want /messages", gotPath) | |
| } | |
| if gotAPIKey != "test-api-key" { | |
| t.Fatalf("x-api-key = %q", gotAPIKey) | |
| } | |
| if gotVersion != "2024-10-22" { | |
| t.Fatalf("anthropic-version = %q", gotVersion) | |
| } | |
| if gotBody != `{"model":"claude-sonnet-4-5"}` { | |
| t.Fatalf("body = %q", gotBody) | |
| } | |
| if resp.StatusCode != http.StatusBadRequest { | |
| t.Fatalf("status = %d, want 400", resp.StatusCode) | |
| } | |
| body, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| t.Fatalf("failed to read response body: %v", err) | |
| } | |
| if string(body) != `{"error":{"message":"bad request"}}` { | |
| t.Fatalf("response body = %q", string(body)) | |
| } | |
| } | |
| func TestPassthrough(t *testing.T) { | |
| var gotPath string | |
| var gotAPIKey string | |
| var gotVersion string | |
| var gotRequestID string | |
| var gotBody string | |
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| gotPath = r.URL.RequestURI() | |
| gotAPIKey = r.Header.Get("x-api-key") | |
| gotVersion = r.Header.Get("anthropic-version") | |
| gotRequestID = r.Header.Get("X-Request-ID") | |
| body, _ := io.ReadAll(r.Body) | |
| gotBody = string(body) | |
| w.Header().Set("Content-Type", "application/json") | |
| w.WriteHeader(http.StatusBadRequest) | |
| _, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`)) | |
| })) | |
| defer server.Close() | |
| provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) | |
| provider.SetBaseURL(server.URL) | |
| resp, err := provider.Passthrough(context.Background(), &core.PassthroughRequest{ | |
| Method: http.MethodPost, | |
| Endpoint: "messages", | |
| Body: io.NopCloser(strings.NewReader(`{"model":"claude-sonnet-4-5"}`)), | |
| Headers: http.Header{ | |
| "Content-Type": {"application/json"}, | |
| "anthropic-version": {"2024-10-22"}, | |
| }, | |
| }) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| defer func() { | |
| _ = resp.Body.Close() | |
| }() | |
| if gotPath != "/messages" { | |
| t.Fatalf("path = %q, want /messages", gotPath) | |
| } | |
| if gotAPIKey != "test-api-key" { | |
| t.Fatalf("x-api-key = %q", gotAPIKey) | |
| } | |
| if gotVersion != "2024-10-22" { | |
| t.Fatalf("anthropic-version = %q", gotVersion) | |
| } | |
| if gotRequestID == "" { | |
| t.Fatal("X-Request-ID should be generated and forwarded") | |
| } | |
| if gotBody != `{"model":"claude-sonnet-4-5"}` { | |
| t.Fatalf("body = %q", gotBody) | |
| } | |
| if resp.StatusCode != http.StatusBadRequest { | |
| t.Fatalf("status = %d, want 400", resp.StatusCode) | |
| } | |
| body, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| t.Fatalf("failed to read response body: %v", err) | |
| } | |
| if string(body) != `{"error":{"message":"bad request"}}` { | |
| t.Fatalf("response body = %q", string(body)) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/providers/anthropic/anthropic_test.go` around lines 3988 - 4047, The
passthrough test needs to assert X-Request-ID generation/propagation: update
TestPassthrough to capture the incoming "X-Request-ID" header from the test
server (add a gotRequestID variable in the httptest handler) and remove any
X-Request-ID from the request Headers in core.PassthroughRequest so the provider
must generate it; after calling provider.Passthrough verify gotRequestID is
non-empty and a valid UUID (or at least matches UUID format), and also add an
additional subcase where you set a known X-Request-ID in the request headers and
assert the same value is forwarded (to cover both generation and propagation
paths). Ensure you modify only TestPassthrough and its request headers; use
NewWithHTTPClient, provider.Passthrough, and core.PassthroughRequest to locate
the code to change.
| func TestGetOpenAICompatibleFileContent(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| id string | ||
| wantPath string | ||
| wantErr bool | ||
| }{ | ||
| {name: "trimmed id uses normalized content endpoint", id: " file_123 ", wantPath: "/files/file_123/content"}, | ||
| {name: "whitespace only is rejected", id: " \n\t", wantErr: true}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| var gotPath string | ||
| var gotMethod string | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| gotPath = r.URL.Path | ||
| gotMethod = r.Method | ||
| w.Header().Set("Content-Type", "application/octet-stream") | ||
| _, _ = w.Write([]byte("file-bytes")) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| client := newOpenAICompatibleTestClient(server) | ||
| resp, err := GetOpenAICompatibleFileContent(context.Background(), client, tt.id) | ||
| if tt.wantErr { | ||
| if err == nil { | ||
| t.Fatal("expected error") | ||
| } | ||
| var gwErr *core.GatewayError | ||
| if !errors.As(err, &gwErr) { | ||
| t.Fatalf("expected GatewayError, got %T: %v", err, err) | ||
| } | ||
| return | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if resp == nil { | ||
| t.Fatal("expected response") | ||
| } | ||
| if gotMethod != http.MethodGet { | ||
| t.Fatalf("method = %q, want GET", gotMethod) | ||
| } | ||
| if gotPath != tt.wantPath { | ||
| t.Fatalf("path = %q, want %q", gotPath, tt.wantPath) | ||
| } | ||
| if resp.ID != "file_123" { | ||
| t.Fatalf("ID = %q, want file_123", resp.ID) | ||
| } | ||
| if string(resp.Data) != "file-bytes" { | ||
| t.Fatalf("body = %q, want file-bytes", string(resp.Data)) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Add a non-2xx regression case for the content path.
GetOpenAICompatibleFileContent() is the only helper here that now goes through DoRaw(), but this test only covers success and input validation. Add a 4xx/5xx response case and assert errors.As(err, &core.GatewayError) plus the expected type so this endpoint stays aligned with the rest of the adapter surface.
As per coding guidelines, **/*_test.go: Add or update tests for any behavior change.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/providers/file_adapter_openai_compat_test.go` around lines 184 -
239, Add a regression sub-test to TestGetOpenAICompatibleFileContent that
simulates a non-2xx response from the content endpoint (e.g., have the httptest
server return a 500 status and optional error body) for a valid trimmed id, call
GetOpenAICompatibleFileContent(ctx, client, id), and assert it returns an error
that matches errors.As(err, &core.GatewayError) and verifies the GatewayError
Type/fields as the other adapter tests do; reference
GetOpenAICompatibleFileContent and the existing test scaffold (httptest server,
newOpenAICompatibleTestClient) to add the 4xx/5xx case alongside the existing
success and input-validation cases.
| reqBody := `{ | ||
| "completion_window":"24h", | ||
| "requests":[ | ||
| { | ||
| "custom_id":"responses-1", | ||
| "method":"POST", | ||
| "url":"https://provider.example/v1/responses/", | ||
| "body":{"model":"gpt-4o-mini","provider":"openai","input":"Hi"} | ||
| } |
There was a problem hiding this comment.
This selector-extraction test is a false positive.
The batch item body hard-codes "provider":"openai", so the handler can ignore the full URL entirely and this test still passes. Remove the explicit provider from the payload, or assert against the value derived from URL normalization/captured batch state.
Suggested tightening
- "body":{"model":"gpt-4o-mini","provider":"openai","input":"Hi"}
+ "body":{"model":"gpt-4o-mini","input":"Hi"}As per coding guidelines, **/*_test.go: Add or update tests for any behavior change.
Also applies to: 2046-2048
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/server/handlers_test.go` around lines 2015 - 2023, The test's JSON
payload (reqBody) hard-codes "provider":"openai", causing a false positive for
the selector-extraction behavior; remove the explicit "provider" key from the
batch item body in the selector-extraction test (and the similar cases around
the other occurrences) so the handler must derive provider from the URL, or
alternatively update the test assertion to check the provider value produced by
URL normalization/captured batch state instead of the hard-coded field; update
the test expectations in the same test function that constructs reqBody and any
assertions that currently rely on the hard-coded provider.
| func TestProviderPassthrough_OpenAI(t *testing.T) { | ||
| provider := &mockProvider{ | ||
| passthroughResponse: &core.PassthroughResponse{ | ||
| StatusCode: http.StatusAccepted, | ||
| Headers: map[string][]string{ | ||
| "Content-Type": {"application/json"}, | ||
| "X-Upstream": {"openai"}, | ||
| "Set-Cookie": {"session=secret"}, | ||
| "Connection": {"X-Upstream-Hop, Keep-Alive"}, | ||
| "X-Upstream-Hop": {"secret"}, | ||
| }, | ||
| Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), | ||
| }, | ||
| } | ||
|
|
||
| e := echo.New() | ||
| handler := NewHandler(provider, nil, nil, nil) | ||
| e.POST("/p/:provider/*", handler.ProviderPassthrough) | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/p/openai/responses?api-version=2026-03-10", strings.NewReader(`{"foo":"bar"}`)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
| req.Header.Set("Authorization", "Bearer user-secret") | ||
| req.Header.Set("Cookie", "session=user-secret") | ||
| req.Header.Set("Forwarded", "for=10.0.0.1") | ||
| req.Header.Set("OpenAI-Beta", "responses=v1") | ||
| req.Header.Set("X-Forwarded-For", "10.0.0.1") | ||
| req.Header.Set("Connection", "X-Debug, keep-alive") | ||
| req.Header.Set("X-Debug", "secret") | ||
| req.Header.Set("X-Request-ID", "req_123") | ||
|
|
||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusAccepted { | ||
| t.Fatalf("status = %d, want %d", rec.Code, http.StatusAccepted) | ||
| } | ||
| if got := rec.Body.String(); got != `{"ok":true}` { | ||
| t.Fatalf("body = %q", got) | ||
| } | ||
| if got := rec.Header().Get("X-Upstream"); got != "openai" { | ||
| t.Fatalf("X-Upstream = %q, want openai", got) | ||
| } | ||
| if got := rec.Header().Get("Set-Cookie"); got != "" { | ||
| t.Fatalf("Set-Cookie should not be forwarded, got %q", got) | ||
| } | ||
| if got := rec.Header().Get("X-Upstream-Hop"); got != "" { | ||
| t.Fatalf("hop-by-hop header should not be forwarded, got %q", got) | ||
| } | ||
| if provider.lastPassthroughProvider != "openai" { | ||
| t.Fatalf("providerType = %q, want openai", provider.lastPassthroughProvider) | ||
| } | ||
| if provider.lastPassthroughReq == nil { | ||
| t.Fatal("lastPassthroughReq = nil") | ||
| } | ||
| if got := provider.lastPassthroughReq.Endpoint; got != "responses?api-version=2026-03-10" { | ||
| t.Fatalf("endpoint = %q", got) | ||
| } | ||
| if got := readPassthroughRequestBody(t, provider.lastPassthroughReq.Body); got != `{"foo":"bar"}` { | ||
| t.Fatalf("body = %q", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("Authorization"); got != "" { | ||
| t.Fatalf("authorization header should not be forwarded, got %q", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("Cookie"); got != "" { | ||
| t.Fatalf("cookie header should not be forwarded, got %q", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("Forwarded"); got != "" { | ||
| t.Fatalf("forwarded header should not be forwarded, got %q", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("X-Forwarded-For"); got != "" { | ||
| t.Fatalf("x-forwarded-for header should not be forwarded, got %q", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("X-Debug"); got != "" { | ||
| t.Fatalf("connection-nominated header should not be forwarded, got %q", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("OpenAI-Beta"); got != "responses=v1" { | ||
| t.Fatalf("OpenAI-Beta = %q, want responses=v1", got) | ||
| } | ||
| if got := provider.lastPassthroughReq.Headers.Get("X-Request-ID"); got != "req_123" { | ||
| t.Fatalf("X-Request-ID = %q, want req_123", got) | ||
| } | ||
| } | ||
|
|
||
| func TestProviderPassthrough_OpenAIV1AliasNormalizesByDefault(t *testing.T) { | ||
| provider := &mockProvider{ | ||
| passthroughResponse: &core.PassthroughResponse{ | ||
| StatusCode: http.StatusOK, | ||
| Headers: map[string][]string{ | ||
| "Content-Type": {"application/json"}, | ||
| }, | ||
| Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), | ||
| }, | ||
| } | ||
|
|
||
| e := echo.New() | ||
| handler := NewHandler(provider, nil, nil, nil) | ||
| e.POST("/p/:provider/*", handler.ProviderPassthrough) | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/p/openai/v1/chat/completions", strings.NewReader(`{"model":"gpt-5-mini"}`)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", rec.Code) | ||
| } | ||
| if provider.lastPassthroughReq == nil { | ||
| t.Fatal("lastPassthroughReq = nil") | ||
| } | ||
| if got := provider.lastPassthroughReq.Endpoint; got != "chat/completions" { | ||
| t.Fatalf("endpoint = %q, want chat/completions", got) | ||
| } | ||
| } | ||
|
|
||
| func TestProviderPassthrough_AnthropicV1AliasNormalizesByDefault(t *testing.T) { | ||
| provider := &mockProvider{ | ||
| passthroughResponse: &core.PassthroughResponse{ | ||
| StatusCode: http.StatusOK, | ||
| Headers: map[string][]string{ | ||
| "Content-Type": {"application/json"}, | ||
| }, | ||
| Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), | ||
| }, | ||
| } | ||
|
|
||
| e := echo.New() | ||
| handler := NewHandler(provider, nil, nil, nil) | ||
| e.POST("/p/:provider/*", handler.ProviderPassthrough) | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/p/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-5"}`)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", rec.Code) | ||
| } | ||
| if provider.lastPassthroughReq == nil { | ||
| t.Fatal("lastPassthroughReq = nil") | ||
| } | ||
| if got := provider.lastPassthroughReq.Endpoint; got != "messages" { | ||
| t.Fatalf("endpoint = %q, want messages", got) | ||
| } | ||
| } | ||
|
|
||
| func TestProviderPassthrough_V1AliasDisabledReturnsBadRequest(t *testing.T) { | ||
| provider := &mockProvider{ | ||
| passthroughResponse: &core.PassthroughResponse{ | ||
| StatusCode: http.StatusOK, | ||
| Headers: map[string][]string{ | ||
| "Content-Type": {"application/json"}, | ||
| }, | ||
| Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), | ||
| }, | ||
| } | ||
|
|
||
| e := echo.New() | ||
| handler := NewHandler(provider, nil, nil, nil) | ||
| handler.normalizePassthroughV1Prefix = false | ||
| e.POST("/p/:provider/*", handler.ProviderPassthrough) | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/p/openai/v1/chat/completions", strings.NewReader(`{"model":"gpt-5-mini"}`)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusBadRequest { | ||
| t.Fatalf("status = %d, want 400", rec.Code) | ||
| } | ||
| if !strings.Contains(rec.Body.String(), "v1 alias is disabled") { | ||
| t.Fatalf("body = %q, want v1 alias error", rec.Body.String()) | ||
| } | ||
| if provider.lastPassthroughReq != nil { | ||
| t.Fatalf("provider should not have been called, got endpoint %q", provider.lastPassthroughReq.Endpoint) | ||
| } | ||
| } | ||
|
|
||
| func TestProviderPassthrough_AnthropicStream(t *testing.T) { | ||
| provider := &mockProvider{ | ||
| passthroughResponse: &core.PassthroughResponse{ | ||
| StatusCode: http.StatusOK, | ||
| Headers: map[string][]string{ | ||
| "Content-Type": {"text/event-stream"}, | ||
| }, | ||
| Body: &chunkedReadCloser{ | ||
| chunks: [][]byte{ | ||
| []byte("event: message_start\n"), | ||
| []byte("data: {\"type\":\"message_start\"}\n\n"), | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| e := echo.New() | ||
| handler := NewHandler(provider, nil, nil, nil) | ||
| e.POST("/p/:provider/*", handler.ProviderPassthrough) | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/p/anthropic/messages", strings.NewReader(`{"model":"claude-sonnet-4-5"}`)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
|
|
||
| rec := &flushCountingRecorder{ResponseRecorder: httptest.NewRecorder()} | ||
| e.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", rec.Code) | ||
| } | ||
| if got := rec.Header().Get("Content-Type"); got != "text/event-stream" { | ||
| t.Fatalf("content-type = %q", got) | ||
| } | ||
| if rec.flushes == 0 { | ||
| t.Fatal("expected streaming response to flush") | ||
| } | ||
| if got := rec.Body.String(); !strings.Contains(got, "message_start") { | ||
| t.Fatalf("unexpected stream body: %q", got) | ||
| } | ||
| } | ||
|
|
||
| func TestProviderPassthrough_RejectsUnsupportedProvider(t *testing.T) { | ||
| provider := &mockProvider{} | ||
|
|
||
| e := echo.New() | ||
| handler := NewHandler(provider, nil, nil, nil) | ||
| e.POST("/p/:provider/*", handler.ProviderPassthrough) | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/p/groq/chat/completions", strings.NewReader(`{}`)) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusBadRequest { | ||
| t.Fatalf("status = %d, want 400", rec.Code) | ||
| } | ||
| if !strings.Contains(rec.Body.String(), "currently supported only for openai and anthropic") { | ||
| t.Fatalf("unexpected error body: %s", rec.Body.String()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Passthrough coverage is still missing two required contract checks.
These tests cover happy paths, but they do not verify that the new passthrough endpoint auto-generates X-Request-ID when the header is absent, and they do not assert typed JSON error bodies for passthrough 4xx/upstream failures. A regression in request-ID propagation or handleError() could still ship with this suite green.
As per coding guidelines, **/*_test.go: Add or update tests for any behavior change, internal/server/**/*.go: Handlers must call handleError() which checks for GatewayError via errors.As and returns typed JSON responses, and {internal/server/**/*.go,internal/providers/**/*.go}: Auto-generate X-Request-ID as UUID if not present in request and propagate through context to providers and audit logs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/server/handlers_test.go` around lines 3045 - 3279, Add two
assertions/tests: (1) in the passthrough happy-path (e.g.,
TestProviderPassthrough_OpenAI or a new
TestProviderPassthrough_AutoGeneratesRequestID) ensure when the incoming request
lacks X-Request-ID the handler generates one and that
provider.lastPassthroughReq.Headers.Get("X-Request-ID") is non-empty (and
optionally UUID-format) and that the response includes the same X-Request-ID
header; (2) add a failing-upstream test (or extend
TestProviderPassthrough_RejectsUnsupportedProvider) that triggers a 4xx/upstream
error from mockProvider and assert the HTTP response body is the typed JSON
error produced by handleError() (i.e., contains structured fields like
code/message) and that errors.As(GatewayError) path is exercised by verifying
the response Content-Type is application/json and X-Request-ID is present; focus
changes around handler.ProviderPassthrough, handleError, GatewayError usage and
mockProvider.passthroughResponse to simulate the error.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
internal/server/model_validation.go (1)
42-61:⚠️ Potential issue | 🟠 MajorFail fast when the model registry is empty.
This path validates selectors against
Supports()without first checking that the registry is initialized. IfModelCount() == 0, every valid request is turned intounsupported model, which is the wrong client error for a cold/failed registry. Guard the empty-registry case beforeSupports()/GetProviderType()and route it throughhandleError()as a typedcore.GatewayError.As per coding guidelines,
{internal/providers/router.go,internal/server/**/*.go}: CheckModelCount() > 0before routing to ensure the registry is initialized.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/server/model_validation.go` around lines 42 - 61, The code currently calls provider.Supports(selector.QualifiedModel()) and provider.GetProviderType(...) without checking whether the model registry is initialized; add a guard that calls provider.ModelCount() (or equivalent) and if it returns 0, return handleError(c, core.NewGatewayError("model registry not initialized", nil)) (or construct a core.GatewayError) before invoking Supports()/GetProviderType(); update the flow in the selectorHintsForValidation/handler block so that ModelCount() > 0 is validated first and only then call Supports(), GetProviderType(), set c.Set(string(providerTypeKey), providerType) and call auditlog.EnrichEntry(c, ...).internal/providers/responses_adapter.go (2)
491-516:⚠️ Potential issue | 🟠 MajorTrim required media fields before treating them as valid.
These paths only reject
"". Inputs like" "forurl,data, orformatcurrently pass adapter validation and fail later inside providers. Trim the required fields first and let conversion reject the request when the trimmed value is empty.✂️ Minimal pattern
- url := v["url"] + url := strings.TrimSpace(v["url"]) if url == "" { return nil, false }Apply the same
strings.TrimSpace(...)check to the typedImageURL,InputAudio, andmap[string]interface{}branches.As per coding guidelines: All errors returned to clients must use
core.GatewayErrorwith typed categories (invalid_request_errorfor malformed input).Also applies to: 550-580, 586-609
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/providers/responses_adapter.go` around lines 491 - 516, Trim required media string fields before validating in the responses adapter so whitespace-only values are rejected: in the "image_url" branch check strings.TrimSpace(part.ImageURL.URL) (and similarly TrimSpace for part.ImageURL.Detail/MediaType if you treat them as required) before accepting and constructing core.ContentPart; in the "input_audio" branch trim strings.TrimSpace(part.InputAudio.Data) and strings.TrimSpace(part.InputAudio.Format) before accepting. When a trimmed required field is empty return an error using core.GatewayError with category "invalid_request_error" (instead of allowing it to pass and fail later). Apply the same TrimSpace validation pattern to the other media branches mentioned (around the other map/typed branches noted).
818-835:⚠️ Potential issue | 🟠 MajorChat→Responses still strips nested media
ExtraFields.The ingress side now preserves
ImageURLContent.ExtraFieldsandInputAudioContent.ExtraFields, but this builder recreates both structs without them. Provider-native keys onimage_url/input_audiosurvive request conversion and are then lost again whenConvertChatResponseToResponsesemits output items.🔁 Suggested fix
items = append(items, core.ResponsesContentItem{ Type: "input_image", ImageURL: &core.ImageURLContent{ - URL: part.ImageURL.URL, - Detail: part.ImageURL.Detail, - MediaType: part.ImageURL.MediaType, + URL: part.ImageURL.URL, + Detail: part.ImageURL.Detail, + MediaType: part.ImageURL.MediaType, + ExtraFields: core.CloneRawJSONMap(part.ImageURL.ExtraFields), }, }) @@ items = append(items, core.ResponsesContentItem{ Type: "input_audio", InputAudio: &core.InputAudioContent{ - Data: part.InputAudio.Data, - Format: part.InputAudio.Format, + Data: part.InputAudio.Data, + Format: part.InputAudio.Format, + ExtraFields: core.CloneRawJSONMap(part.InputAudio.ExtraFields), }, })As per coding guidelines: Add or update tests for any behavior change.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/providers/responses_adapter.go` around lines 818 - 835, The ConvertChatResponseToResponses builder is recreating core.ImageURLContent and core.InputAudioContent without carrying over ExtraFields, causing provider-native keys on image_url/input_audio to be dropped; update the switch branch that constructs these items (the code creating core.ResponsesContentItem for Type "input_image" and "input_audio") to populate the ExtraFields field from the source parts (e.g., set ImageURL.ExtraFields = part.ImageURL.ExtraFields and InputAudio.ExtraFields = part.InputAudio.ExtraFields, or otherwise copy the original struct including ExtraFields) and add/update unit tests for ConvertChatResponseToResponses to assert that ExtraFields round-trip for both image_url and input_audio cases.
♻️ Duplicate comments (1)
config/config_test.go (1)
304-329:⚠️ Potential issue | 🟡 MinorMake the YAML-expansion case hermetic.
normalize_passthrough_v1_prefixis exercised through${PASSTHROUGH_NORMALIZE_FROM_YAML:-false}, but this test never clears or setsPASSTHROUGH_NORMALIZE_FROM_YAML. If that variable exists in the runner environment, the default branch is skipped and this assertion becomes nondeterministic.♻️ Minimal fix
func TestLoad_PassthroughFlags_YAMLExpansion(t *testing.T) { withTempDir(t, func(dir string) { clearAllConfigEnvVars(t) t.Setenv("PASSTHROUGH_ENABLED_FROM_YAML", "false") + t.Setenv("PASSTHROUGH_NORMALIZE_FROM_YAML", "") yaml := ` server:As per coding guidelines,
config/*.go: Config loading follows cascade: code defaults →config/config.yaml(supports${VAR}and${VAR:-default}expansion) → environment variables (always win).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@config/config_test.go` around lines 304 - 329, TestLoad_PassthroughFlags_YAMLExpansion is nondeterministic because PASSTHROUGH_NORMALIZE_FROM_YAML may be present in the environment; ensure the test is hermetic by explicitly removing that env var before loading the config. In the TestLoad_PassthroughFlags_YAMLExpansion function, call os.Unsetenv("PASSTHROUGH_NORMALIZE_FROM_YAML") (or otherwise ensure it is not set) before writing the YAML and calling Load(), so the `${PASSTHROUGH_NORMALIZE_FROM_YAML:-false}` expansion uses the default and the assertion on result.Config.Server.NormalizePassthroughV1Prefix is deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@config/config.example.yaml`:
- Around line 10-12: The example config.yaml must be updated to match how
config/config.go now reads cache settings under cache.model; move the top-level
cache keys (type, cache_dir, refresh_interval) into a nested cache.model block
and ensure the key names match those expected by the Config parser in
config/config.go (e.g., Cache.Model.Type, Cache.Model.CacheDir,
Cache.Model.RefreshInterval); update supported_passthrough/providers lines if
needed to remain consistent, then test by loading the example with the Config
loader to confirm the cache settings are picked up.
In `@internal/providers/responses_adapter.go`:
- Around line 251-255: The code currently silently drops non-JSON-serializable
tool outputs by using stringifyResponsesInputValue (which returns "" on
json.Marshal failure); change the logic in the Response->core.Message
construction (the branch that sets Role "tool" and ToolCallID using
stringifyResponsesInputValue) and the similar block in convertResponsesInputMap
(lines ~306-309) to detect marshalling errors from stringifyResponsesInputValue
and return a Gateway error instead of an empty payload: call the underlying
marshalling helper so you can capture its error, and when it fails return
core.GatewayError (typed as invalid_request_error / use the existing core helper
for invalid request errors, e.g., core.NewInvalidRequestError or the project’s
Gateway error constructor) with a clear message about non-JSON-serializable
function_call_output.output rather than constructing a tool message with an
empty Content. Ensure both stringifyResponsesInputValue call sites (the
tool-message path and convertResponsesInputMap) follow this pattern.
In `@internal/server/handlers.go`:
- Around line 361-372: The passthroughStreamAuditPath function only normalizes
OpenAI endpoints, causing Anthropic endpoints like "/messages" to remain
unprefixed; update passthroughStreamAuditPath to add Anthropic handling by
normalizing known Anthropic endpoints (e.g., "/messages") to their API-prefix
form (e.g., "/v1/messages") when providerType == "anthropic", similar to the
existing OpenAI switch, using the same normalized variable derived from endpoint
and falling back to returning requestPath if no match; modify the switch or add
a separate switch/case for providerType "anthropic" and ensure you reference
passthroughStreamAuditPath, providerType, requestPath, and endpoint when
implementing the fix.
In `@internal/server/model_validation.go`:
- Around line 72-87: selectorHintsForValidation currently short-circuits and
returns parsed=false whenever core.GetIngressFrame(ctx) != nil and
!frame.RawBodyTooLarge, which skips model validation even when no selector hints
exist; change that behavior so we only bypass the live-body read when selector
hints were already resolved by the envelope/cached/decoded paths (the
cachedCanonicalSelectorHints, decodeCanonicalSelectorHintsForValidation checks
or env.SelectorHints). Concretely, remove or tighten the frame branch that
unconditionally returns "", "", false, nil and instead only return parsed=false
if a selector was already found; otherwise fall through to the existing
read+rewind path (or return a closed-fail) so missing/unsupported models are
validated. Ensure references: selectorHintsForValidation, core.GetIngressFrame,
frame.RawBodyTooLarge, cachedCanonicalSelectorHints,
decodeCanonicalSelectorHintsForValidation, and env.SelectorHints.
---
Outside diff comments:
In `@internal/providers/responses_adapter.go`:
- Around line 491-516: Trim required media string fields before validating in
the responses adapter so whitespace-only values are rejected: in the "image_url"
branch check strings.TrimSpace(part.ImageURL.URL) (and similarly TrimSpace for
part.ImageURL.Detail/MediaType if you treat them as required) before accepting
and constructing core.ContentPart; in the "input_audio" branch trim
strings.TrimSpace(part.InputAudio.Data) and
strings.TrimSpace(part.InputAudio.Format) before accepting. When a trimmed
required field is empty return an error using core.GatewayError with category
"invalid_request_error" (instead of allowing it to pass and fail later). Apply
the same TrimSpace validation pattern to the other media branches mentioned
(around the other map/typed branches noted).
- Around line 818-835: The ConvertChatResponseToResponses builder is recreating
core.ImageURLContent and core.InputAudioContent without carrying over
ExtraFields, causing provider-native keys on image_url/input_audio to be
dropped; update the switch branch that constructs these items (the code creating
core.ResponsesContentItem for Type "input_image" and "input_audio") to populate
the ExtraFields field from the source parts (e.g., set ImageURL.ExtraFields =
part.ImageURL.ExtraFields and InputAudio.ExtraFields =
part.InputAudio.ExtraFields, or otherwise copy the original struct including
ExtraFields) and add/update unit tests for ConvertChatResponseToResponses to
assert that ExtraFields round-trip for both image_url and input_audio cases.
In `@internal/server/model_validation.go`:
- Around line 42-61: The code currently calls
provider.Supports(selector.QualifiedModel()) and provider.GetProviderType(...)
without checking whether the model registry is initialized; add a guard that
calls provider.ModelCount() (or equivalent) and if it returns 0, return
handleError(c, core.NewGatewayError("model registry not initialized", nil)) (or
construct a core.GatewayError) before invoking Supports()/GetProviderType();
update the flow in the selectorHintsForValidation/handler block so that
ModelCount() > 0 is validated first and only then call Supports(),
GetProviderType(), set c.Set(string(providerTypeKey), providerType) and call
auditlog.EnrichEntry(c, ...).
---
Duplicate comments:
In `@config/config_test.go`:
- Around line 304-329: TestLoad_PassthroughFlags_YAMLExpansion is
nondeterministic because PASSTHROUGH_NORMALIZE_FROM_YAML may be present in the
environment; ensure the test is hermetic by explicitly removing that env var
before loading the config. In the TestLoad_PassthroughFlags_YAMLExpansion
function, call os.Unsetenv("PASSTHROUGH_NORMALIZE_FROM_YAML") (or otherwise
ensure it is not set) before writing the YAML and calling Load(), so the
`${PASSTHROUGH_NORMALIZE_FROM_YAML:-false}` expansion uses the default and the
assertion on result.Config.Server.NormalizePassthroughV1Prefix is deterministic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: e7fb3057-33a0-4af5-9c15-a7a49c07cff4
📒 Files selected for processing (21)
README.mdconfig/config.example.yamlconfig/config.goconfig/config_test.gointernal/app/app.gointernal/auditlog/auditlog_test.gointernal/auditlog/middleware.gointernal/core/ingress.gointernal/core/ingress_test.gointernal/core/semantic.gointernal/core/semantic_test.gointernal/providers/responses_adapter.gointernal/providers/responses_adapter_test.gointernal/server/handlers.gointernal/server/handlers_test.gointernal/server/http.gointernal/server/ingress.gointernal/server/ingress_test.gointernal/server/model_validation.gointernal/server/model_validation_test.gointernal/server/semantic_requests_test.go
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
internal/providers/responses_adapter.go (1)
251-256:⚠️ Potential issue | 🟡 MinorTyped
function_call_outputpath silently drops non-serializable outputs.The map-based
function_call_outputpath (lines 305-311) usesstringifyResponsesInputValueWithErrorand returns a proper error for non-JSON-serializable values. However, this typed path usesstringifyResponsesInputValuewhich silently returns an empty string on marshal failure.For consistency and to avoid silent data loss, use the error-checked variant here as well.
🐛 Proposed fix
case "function_call_output": callID := strings.TrimSpace(item.CallID) if callID == "" { return core.Message{}, "", core.NewInvalidRequestError(fmt.Sprintf("invalid responses input item at index %d: function_call_output call_id is required", index), nil) } + content, err := stringifyResponsesInputValueWithError(item.Output) + if err != nil { + return core.Message{}, "", core.NewInvalidRequestError( + fmt.Sprintf("invalid responses input item at index %d: function_call_output.output must be JSON-serializable", index), + err, + ) + } return core.Message{ Role: "tool", ToolCallID: callID, - Content: stringifyResponsesInputValue(item.Output), + Content: content, ExtraFields: core.CloneRawJSONMap(item.ExtraFields), }, "function_call_output", nil🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/providers/responses_adapter.go` around lines 251 - 256, The typed "function_call_output" branch currently calls stringifyResponsesInputValue(item.Output) which swallows marshal errors; replace that call with stringifyResponsesInputValueWithError(item.Output), check the returned error, and if non-nil return an error (instead of producing an empty Content) so the caller sees serialization failures; update the return in the affected block where core.Message is constructed (the line with Content: stringifyResponsesInputValue(item.Output)) to use the error-checked variant and propagate the error up.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@config/config_test.go`:
- Around line 332-367: The test
TestLoad_ConfigExample_UsesNestedModelCacheSettings currently writes
config.example.yaml to a temp config.yaml and calls Load(), but never asserts
the server.supported_passthrough_providers YAML value; add a no-env assertion
that the parsed list from Load() matches the YAML list (e.g.,
result.Config.Server.SupportedPassthroughProviders equals the expected slice
from config.example.yaml) to prevent regressions where YAML loading might break;
reuse clearAllConfigEnvVars(t) and the existing exampleData/temp file setup so
the assertion runs without env overrides and reference Load() and
result.Config.Server.SupportedPassthroughProviders when adding the check.
In `@internal/guardrails/provider.go`:
- Around line 53-59: GuardedProvider.ModelCount currently returns 0 when g.inner
does not implement ModelCount(), which is interpreted elsewhere as "registry not
initialized"; change the fallback to return a sentinel for "unknown" (e.g. -1)
instead of 0 so wrapped providers/mocks that don't expose ModelCount() are not
treated as uninitialized. Locate the ModelCount method on GuardedProvider and
replace the fallback return value with -1 (or another agreed-upon sentinel) and
ensure any callers that check for initialization treat 0 as uninitialized and
negative values as "unknown" (adjust tests or checks in
internal/server/model_validation.go if needed).
In `@internal/server/handlers.go`:
- Around line 405-408: The passthrough error log is using the raw header value
c.Request().Header.Get("X-Request-ID") which misses the resolved/auto-generated
request ID carried in context; change the call site around
flushStream/recordStreamingError so you first obtain the canonical request ID
from the request context (e.g. a helper that reads the request-scoped ID placed
earlier in middleware, falling back to the header if needed) and pass that
resolved ID into recordStreamingError (instead of
c.Request().Header.Get("X-Request-ID")), keeping the other parameters
(streamEntry, "", providerType, c.Request().URL.Path, err) the same. Ensure the
helper name you call matches the project's request-ID accessor used elsewhere so
the auto-generated UUID propagated into context is used for streaming logs.
---
Duplicate comments:
In `@internal/providers/responses_adapter.go`:
- Around line 251-256: The typed "function_call_output" branch currently calls
stringifyResponsesInputValue(item.Output) which swallows marshal errors; replace
that call with stringifyResponsesInputValueWithError(item.Output), check the
returned error, and if non-nil return an error (instead of producing an empty
Content) so the caller sees serialization failures; update the return in the
affected block where core.Message is constructed (the line with Content:
stringifyResponsesInputValue(item.Output)) to use the error-checked variant and
propagate the error up.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 63d9f28b-3ae0-4a27-ae13-80c3fbb3def1
📒 Files selected for processing (10)
config/config.example.yamlconfig/config_test.gointernal/guardrails/provider.gointernal/providers/responses_adapter.gointernal/providers/responses_adapter_test.gointernal/providers/router.gointernal/server/handlers.gointernal/server/handlers_test.gointernal/server/model_validation.gointernal/server/model_validation_test.go
Summary by CodeRabbit
New Features
Documentation
Tests
Chores