fix(edge): provider chat 응답을 정규화한다

This commit is contained in:
toki 2026-08-14 01:02:43 +09:00
parent 73d9b51432
commit 8dd0c9c297
6 changed files with 164 additions and 13 deletions

View file

@ -180,11 +180,14 @@ unbounded identifier. Success, length, and cancelled terminals do not emit this
Private Plan/Work/Review Chat Completions responses may contain the standard bounded
`usage` bookkeeping object (`prompt_tokens`, `completion_tokens`, `total_tokens`, and
their standard detail objects) and an optional string `message.reasoning_content`.
The stage decoder validates the known envelope shape and discards these private values;
their standard detail objects), top-level `service_tier`/`system_fingerprint`, choice
`logprobs`, message `annotations`, a null `refusal`, and an optional string
`message.reasoning_content`. The shared provider-normalization layer removes the
bookkeeping fields and emits the same canonical private Chat-shaped envelope used for
Responses results before the stage decoder runs. These private values are discarded;
they do not enter a stage result or artifact and do not select a route, credential,
workspace, tool, or terminal. A non-string reasoning value and unknown or duplicate
response members still fail closed. The external Claude
workspace, tool, or terminal. A non-null refusal, non-string reasoning value, and
unknown or duplicate response members still fail closed. The external Claude
qualification harness also disables SDK retry and automatic session-title generation
only in its supervised child so the single observed Messages ingress is the actual task.

View file

@ -190,7 +190,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. |
| managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. |
| marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. |
| marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Responses results are converted back to the private common Chat-shaped stage codec before Plan/Work/Review validation. |
| marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard bookkeeping (`service_tier`, `system_fingerprint`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. |
| marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays, and Edge owns the bullet formatting and renders `plan.md`, while the Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. |
| marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. |
| marked single-request S11 terminal policy | The service freezes one closed `end_turn`, `length`, `error`, or `cancelled` disposition. `error` classes are provider, validation, timeout, budget, repetition, malformed, context, internal-tool, and workspace-cleanup. Buffered and SSE share one projection: `end_turn`; `max_tokens` with no private partial output; `400 invalid_request_error` for validation/context; `502 api_error` for other failures; and silent cancellation after caller disconnect. No terminal classification retries, falls back, opens a second request, or later writes success. |
@ -381,6 +381,7 @@ sequenceDiagram
- 2026-08-09: Extended `output_config.effort` to accept `low`, `medium`, `high`, `xhigh`, and `max` across Anthropic native and Chat bridge routes without substitution or normalization. Unknown effort values remain `400 invalid_request_error` before provider dispatch. Deterministic Go coverage added for exact bridge mapping, native `max` preservation, and invalid-value rejection. (`apps/edge/internal/openai/anthropic_types.go`, `apps/edge/internal/openai/anthropic_bridge_test.go`, `apps/edge/internal/openai/anthropic_native_test.go`)
- 2026-08-13: Added caller-neutral provider operation normalization for Messages/Responses routes. Tool-bearing adaptive effort can select Responses when Chat cannot preserve the combination, and unsupported effort grades fall only to the nearest declared lower grade (for example `max` to `xhigh`).
- 2026-08-14: Applied the same provider-normalization boundary to private single-request Plan/Work/Review stages. Removed pre-selection Chat operation pinning, added Chat↔Responses stage conversion and nearest-lower effort mapping, and corrected default-selector dispatch validation so the pool-selected provider is accepted without weakening explicit provider, profile, credential, target, or execution-path fences.
- 2026-08-14: Canonicalized private Chat Completions stage responses before strict Plan/Work/Review decoding. Standard OpenAI bookkeeping fields are stripped centrally, a non-null refusal remains fail-closed, and Responses/Chat now converge on the same private envelope instead of making stage codecs provider-specific.
- 2026-08-13: Gemini-native path parsing now keeps route IDs strict while accepting the bounded URL-encoded official caller model label `Gemini 3.6 Flash`.
- 2026-08-06: Synchronized always-owned Chat/Responses typed-stall recovery, provider avoidance/fallback admission, and closed-label liveness operational evidence with the current runtime, contracts, and deterministic recovery tests.
- 2026-08-06: Added marked single-request Messages admission through the separate service coordinator capability, one unlabeled runtime ingress counter, buffered sanitized terminal acknowledgement, and deterministic real-POST compatibility evidence.

View file

@ -222,14 +222,14 @@ The shared `packages/go/execution` package contains provider lifecycle, registry
| normalized execution | `adapter + target`으로 provider 실행을 선택하고 ordered `RunEvent` stream을 반환한다. |
| single-request coordinator | Immutable admission과 closed stage envelope을 service-owned state graph (`accepted`, `planning`, `working`, `reviewing`, `repairing`, `internal_tool`, `finalizing`, `completed`, `failed`, `cancelled`)로 처리한다. An internal tool result can resume only its saved stage. After a successful workspace open, every terminal path waits for one cleanup before the finalizing candidate can reach surface acknowledgement. |
| single-request S11 terminal policy | One validated, copy-safe terminal disposition is frozen across envelope/result/progress with kinds `end_turn`, `length`, `error`, and `cancelled`. Error classes are `provider`, `validation`, `timeout`, `budget`, `repetition`, `malformed`, `context`, `internal_tool`, and `workspace_cleanup`. Cleanup can replace a pending success/length before publication; no acknowledgement race can publish a second terminal. |
| single-request observation evidence | Stage-pure timing, tool/cleanup/total counts, cardinality-bounded labels, Node logs, and raw-free correlation are documented for the single-request path. `iop_anthropic_single_request_ingress_total` is strictly unlabeled. A separate `edge_single_request_terminal_rejection` event projects only the fixed terminal kind/error class and HTTP status, so `malformed` and `validation` remain distinguishable without raw model output. The private stage decoder accepts and discards only bounded standard Chat Completions `usage` bookkeeping and optional string `message.reasoning_content`; neither enters stage results or artifacts, while non-string reasoning and unknown envelope members fail closed. Gemini Plan/Review also admit only exact non-empty `extra_content.google.thought_signature`; terminal signatures are discarded and a Review tool-call signature is replayed only in the matching request-local Gemini continuation. Work, artifacts, results, and observations never retain it. The Claude qualification child disables SDK retry and session-title generation so only the actual task can consume ingress. Internal tool names, raw arguments, and private results are absent from public output and log projections. SDD S12 qualified the external Claude path on an approved IOP Node with ingress `0→1`, Gemini → ornith-fast → Gemini stage order, one `end_turn`, the exact expected workspace result, timing, cleanup, and redacted evidence. |
| single-request observation evidence | Stage-pure timing, tool/cleanup/total counts, cardinality-bounded labels, Node logs, and raw-free correlation are documented for the single-request path. `iop_anthropic_single_request_ingress_total` is strictly unlabeled. A separate `edge_single_request_terminal_rejection` event projects only the fixed terminal kind/error class and HTTP status, so `malformed` and `validation` remain distinguishable without raw model output. The provider-normalization layer accepts and discards bounded standard Chat Completions bookkeeping (`usage`, `service_tier`, `system_fingerprint`, choice `logprobs`, message `annotations`, null `refusal`) plus optional string `message.reasoning_content` before emitting the canonical private stage envelope; none enters stage results or artifacts, while a non-null refusal, non-string reasoning, and unknown envelope members fail closed. Gemini Plan/Review also admit only exact non-empty `extra_content.google.thought_signature`; terminal signatures are discarded and a Review tool-call signature is replayed only in the matching request-local Gemini continuation. Work, artifacts, results, and observations never retain it. The Claude qualification child disables SDK retry and session-title generation so only the actual task can consume ingress. Internal tool names, raw arguments, and private results are absent from public output and log projections. SDD S12 qualified the external Claude path on an approved IOP Node with ingress `0→1`, Gemini → ornith-fast → Gemini stage order, one `end_turn`, the exact expected workspace result, timing, cleanup, and redacted evidence. |
| workspace admission | An opaque `workspace_ref` resolves only through the configured Node catalog. Edge freezes the exact configured owner, dispatch-ready connection generation, closed operation/command/environment-name capabilities, and effective limits before executor startup; unavailable, foreign, pending, malformed, and stale candidates fail closed without fallback or reselection. |
| workspace runtime wire | The dedicated `WorkspaceOpen`/`Tool`/`Artifact`/`Cancel`/`Cleanup` request-response families carry immutable coordinator identities and closed status/error codes. `WorkspaceArtifact` admits only enum-selected `PLAN`/`REVIEW` and `READ`/`WRITE`; it carries no relative path. Edge overwrites open capabilities with frozen request authority; Node copies only catalog-subset operations/command ids and equal-or-lower effective limits. |
| workspace tool executor | A validated `darwin|linux` Node catalog owns opened root and directory handles only when every entry platform matches the host exactly. Windows, unknown hosts, and cross-platform catalogs fail before root open; empty catalogs remain compatible. Go 1.24-compatible no-follow file primitives provide bounded read, bounded list, structured write, and non-recursive delete. Exact operator-owned command templates run through an inherited-root `fchdir`/`exec` shim with minimal allowlisted environment, shared stdout/stderr bounds, process-group timeout/cancel, and stable typed results. OS is runtime evidence rather than a caller-visible selector. |
| internal workspace tool loop | The service decodes only `workspace_read`, `workspace_list`, `workspace_write`, `workspace_delete`, and `workspace_command`, opens the admitted workspace once, dispatches one call at a time on the frozen generation, and delivers one deep-copied typed result to the emitting executor continuation. Unique request/stage/tool correlation, per-stage iteration/output/deadline limits, request wall-clock budget, and typed cancel fail closed without external continuation or reselection. |
| request-owned internal artifacts | `SingleRequestController` exposes closed plan/review read/write operations. Artifact calls and model workspace tools share one serialized lazy `WorkspaceOpen`, the exact admitted Node generation, the active stage deadline, the immutable output bound, in-flight work accounting, and one terminal cleanup. Node alone maps selectors to `plan.md` and `review.md`, and inventoried descriptor-relative reads fail closed on identity replacement. |
| Plan stage | The Plan runner validates the frozen effective template, emits the `planning` envelope, sends the immutable task through the frozen Plan binding with `reasoning_effort=high` and a stage-owned strict JSON schema for one-line `goal` plus bounded one-line `steps`/`verification` arrays, validates the fields, adds Markdown bullets, renders the template deterministically inside Edge, and writes the resulting Markdown through `SingleRequestArtifactPlan`. |
| single-request provider normalization | Private Plan/Work/Review calls pass caller-neutral effort/tool/structured-output requirements to the selected protocol profile. The profile chooses Chat Completions or Responses and maps unsupported effort only downward. An explicit managed selector freezes the exact provider ID; `default` freezes no provider ID and accepts the provider pool's concrete choice while retaining exact model-group/profile/target/credential/tunnel fences. No new Edge-Node field is added: the selected operation continues through the existing provider tunnel operation field. |
| single-request provider normalization | Private Plan/Work/Review calls pass caller-neutral effort/tool/structured-output requirements to the selected protocol profile. The profile chooses Chat Completions or Responses and maps unsupported effort only downward. An explicit managed selector freezes the exact provider ID; `default` freezes no provider ID and accepts the provider pool's concrete choice while retaining exact model-group/profile/target/credential/tunnel fences. Chat and Responses provider results are both reduced to one canonical private Chat-shaped envelope before strict stage decoding. No new Edge-Node field is added: the selected operation continues through the existing provider tunnel operation field. |
| single-request effective templates | `execution_presets[].single_request.templates` optionally loads `plan_file`/`review_file` as bounded Markdown relative to the directory containing `edge.yaml`; absolute and empty paths, non-regular files, oversize (`>8192` bytes), non-UTF-8, and invalid grammar fail closed at load, and each file falls back to its built-in default independently. Admission freezes the effective Plan/Review pair into the binding, so a later refresh reaches only newly admitted requests. Templates select internal stage input and internal artifact shape only; caller request/response schemas are unchanged. |
| Work stage | The `ornith-fast` Work runner reads the closed PLAN artifact, projects only the admitted workspace tools, and resumes the same frozen provider route after exactly correlated Node results. It rejects any Work `reasoning_effort`, malformed or multiple tool calls, and empty completion or verification evidence. |
| request-owned cleanup | Node creates and inventories only `.iop/job/<request_id>` internal state, cancels and waits for all active command groups, validates the exact tree without following entries, and removes matching artifacts deepest-first with non-recursive descriptor operations. Symlinks, special files, foreign devices, identity replacements, and unowned entries fail closed. User results and sibling request state are preserved. Concurrent cleanup callers receive one bounded cached typed result. |
@ -368,6 +368,7 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l
## 변경 기록
- 2026-08-14: Moved private Plan/Work/Review provider calls onto the shared provider-normalization boundary. Stage requirements now select Chat or Responses without caller identity, unsupported effort maps only downward, and default-selector provider-pool choices no longer fail the post-dispatch validation that still fences profile, target, credential revision, model group, and tunnel path.
- 2026-08-14: Added common Chat result normalization for private stages so standard OpenAI bookkeeping fields are removed before strict decoding, matching the existing Responses-to-common conversion while preserving fail-closed refusal and unknown-field handling.
- 2026-08-12: Replaced nondeterministic free-form PlanMD generation with a stage-owned strict `goal`/`steps`/`verification` JSON response. Edge rejects unknown, duplicate, missing, mistyped, or out-of-bound fields and deterministically renders the already-frozen operator Plan template, preserving template customization and every caller-visible schema (`apps/edge/internal/openai/single_request_plan_stage.go`, `packages/go/singlerequesttemplate/template.go`).
- 2026-08-12: Replaced Markdown-bearing Plan string fields with bounded one-line string arrays and moved all bullet/newline rendering into Edge, removing the remaining provider-specific formatting dependency without changing the public Messages surface (`apps/edge/internal/openai/single_request_plan_stage.go`, `packages/go/singlerequesttemplate/template.go`).
- 2026-08-09: Replaced the Plan stage's structured JSON response contract with operator-configurable bounded Markdown templates. `execution_presets[].single_request.templates` resolves `plan_file`/`review_file` relative to the directory containing `edge.yaml` only, admission freezes the effective Plan/Review pair so a refresh reaches only newly admitted requests, the Plan stage prompts with and parses direct PlanMD against the frozen template, and the Review stage renders the internal REVIEW artifact from the frozen Review template while the caller-visible final response stays the model's `decision.output` (`packages/go/singlerequesttemplate/template.go`, `packages/go/config/load.go`, `apps/edge/internal/openai/single_request_preset_binding.go`, `apps/edge/internal/openai/single_request_plan_stage.go`, `apps/edge/internal/openai/single_request_review_stage.go`).

View file

@ -26,17 +26,17 @@
|---|---|---|---|
| Claude Code → Claude direct | 통과 | 없음 | 기존 성공 |
| Claude Code → Gemini direct | 통과 | 없음 | 기존 성공 |
| agy → Gemini direct | 1초, caller login 요구 | `modelProvider=gemini` 보정 뒤 8초, HTTP 400 | IOP Gemini path가 공식 URL-encoded model label을 거부 |
| agy → Gemini direct | 1초, caller login 요구 | parser 배포 뒤 19-22초, caller `SUCCESS` 2회, 파일 없음 | IOP parser 결함 해소; agy가 trusted workspace에서도 파일 도구를 호출하지 않은 caller/tool 이슈 분리 |
| Claude Code → GPT direct | provider HTTP 400 | operation normalization 반영 뒤 9초, caller success, `index.html` marker 1회 | IOP가 tools+effort를 Chat으로 보낸 결함 수정 확인 |
| Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 공식 설정대로 임시 `CODEX_HOME`, Responses 전용 provider, `CODEX_CA_CERTIFICATE`에 CA bundle을 사용해 10초 통과 | 측정 환경 결함: 첫 호출은 CA bundle 대신 Edge leaf 인증서를 사용 |
| Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 파일 판정은 정상으로 정정; metric에서 Plan `validation` 종료 확인 | 측정 판정 결함과 제품 dispatch 검증 결함을 분리; default selector의 실제 provider ID를 오판 |
| Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 새 배포에서 33초 caller 정상 종료; Plan/Work/Review, workspace write/read/list, artifact 3개 cleanup 성공 | default-selector dispatch 결함 해소; terminal 문구에 marker가 없는 것은 caller-visible 결과 판정과 분리 |
| agy → Gemini execution preset | 미실행 | direct parser 수정 배포 대기 | 선행 결함 |
| Claude Code → GPT execution preset | 미실행 | 10초, request terminal `validation`, marker 0회 | provider 호출 뒤 default selector의 실제 provider ID를 고정값 불일치로 오판; 국소 수정·회귀 통과, 배포 대기 |
| Claude Code → GPT execution preset | 미실행 | selector 수정 배포 뒤 9초, provider HTTP 200 이후 `malformed`; 실제 Chat 응답의 표준 bookkeeping field를 private strict codec이 거부 | 공통 Chat 응답 정규화 누락 확인·국소 수정 및 전체 Edge 회귀 통과; 재배포 대기 |
| Codex → GPT execution preset | 16초, `turn.completed`, terminal marker 1회 | 없음 | 통과 |
추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. Gemini-native 최소 요청은 canonical caller model id에서 HTTP 200, 공식 표시 label `Gemini 3.6 Flash`에서 HTTP 400으로 갈려 path parser 결함을 재현했다.
두 preset 실패는 provider 자체 실패가 아니었다. GPT/Gemini 모두 구조화 Plan provider 호출을 마친 뒤 같은 `validation` terminal로 닫혔고, 코드 대조에서 `default` resource selector가 의도적으로 빈 provider ID를 동결하는 반면 사후 검증은 pool이 정상 선택한 실제 provider ID와 무조건 같아야 한다고 요구한 결함을 확인했다. explicit selector의 provider ID와 profile/model/credential/path fence는 유지하고, default selector만 pool 선택을 인정하는 회귀 테스트가 통과했다. 같은 private stage 경계에서 operation을 선고정하지 않고 profile별 Chat/Responses 선택과 nearest-lower effort mapping을 적용했다.
두 preset 최초 실패는 provider 자체 실패가 아니었다. 코드 대조에서 `default` resource selector가 의도적으로 빈 provider ID를 동결하는 반면 사후 검증은 pool이 정상 선택한 실제 provider ID와 무조건 같아야 한다고 요구한 결함을 확인했다. explicit selector의 provider ID와 profile/model/credential/path fence는 유지하고, default selector만 pool 선택을 인정했다. 재배포 뒤 Gemini preset은 전체 stage와 workspace lifecycle이 통과했다. GPT preset은 다음 경계까지 진행해 실제 OpenAI Chat 응답의 `service_tier`, `system_fingerprint`, `annotations`, null `refusal`을 private strict decoder가 거부하는 별도 normalize 누락을 드러냈다. 공통 provider-normalization 계층에서 Chat/Responses 결과를 같은 canonical stage envelope로 수렴시키는 국소 회귀가 통과했다.
## 재개 조건

View file

@ -1,6 +1,7 @@
package openai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@ -57,6 +58,73 @@ type openAIResponsesBridgeResponse struct {
} `json:"incomplete_details"`
}
// singleRequestChatBridgeResponse admits the portable Chat Completions response
// fields needed by private stages. Provider-selected bookkeeping such as
// service_tier, system_fingerprint, annotations, and logprobs is intentionally
// left outside the canonical stage envelope.
type singleRequestChatBridgeResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []singleRequestChatBridgeChoice `json:"choices"`
Usage json.RawMessage `json:"usage"`
}
type singleRequestChatBridgeChoice struct {
Index int `json:"index"`
FinishReason string `json:"finish_reason"`
Message singleRequestChatBridgeMessage `json:"message"`
}
type singleRequestChatBridgeMessage struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls json.RawMessage `json:"tool_calls"`
ReasoningContent *string `json:"reasoning_content"`
ExtraContent json.RawMessage `json:"extra_content"`
Refusal json.RawMessage `json:"refusal"`
}
func (v *singleRequestChatBridgeResponse) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "id", "object", "created", "model", "choices", "usage", "service_tier", "system_fingerprint"); err != nil {
return err
}
type alias singleRequestChatBridgeResponse
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeResponse(decoded)
return nil
}
func (v *singleRequestChatBridgeChoice) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "index", "finish_reason", "message", "logprobs"); err != nil {
return err
}
type alias singleRequestChatBridgeChoice
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeChoice(decoded)
return nil
}
func (v *singleRequestChatBridgeMessage) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "role", "content", "tool_calls", "reasoning_content", "extra_content", "refusal", "annotations"); err != nil {
return err
}
type alias singleRequestChatBridgeMessage
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeMessage(decoded)
return nil
}
func singleRequestProviderRequirements(options map[string]any, tools []any, responseFormat *singleRequestProviderResponseFormat) providerRequestRequirements {
requirements := providerRequestRequirements{HasTools: len(tools) > 0, StructuredOutput: responseFormat != nil}
if effort, ok := options["reasoning_effort"].(string); ok {
@ -236,9 +304,61 @@ func singleRequestResponsesToolChoice(choice any) any {
}
func normalizeSingleRequestProviderResponse(body []byte, dispatch edgeservice.RunDispatch) ([]byte, error) {
if dispatch.ProfileOperation != string(config.OperationResponses) {
return body, nil
switch dispatch.ProfileOperation {
case string(config.OperationResponses):
return normalizeSingleRequestResponsesResponse(body)
case string(config.OperationChatCompletions), "":
return normalizeSingleRequestChatResponse(body)
default:
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
}
func normalizeSingleRequestChatResponse(body []byte) ([]byte, error) {
if err := validateSingleRequestJSON(body); err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
var response singleRequestChatBridgeResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
choices := make([]any, 0, len(response.Choices))
for _, choice := range response.Choices {
if refusal := bytes.TrimSpace(choice.Message.Refusal); len(refusal) > 0 && !bytes.Equal(refusal, []byte("null")) {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
message := map[string]any{
"role": choice.Message.Role,
"content": choice.Message.Content,
}
if len(choice.Message.ToolCalls) > 0 {
message["tool_calls"] = choice.Message.ToolCalls
}
if choice.Message.ReasoningContent != nil {
message["reasoning_content"] = choice.Message.ReasoningContent
}
if len(choice.Message.ExtraContent) > 0 {
message["extra_content"] = choice.Message.ExtraContent
}
choices = append(choices, map[string]any{
"index": choice.Index, "finish_reason": choice.FinishReason, "message": message,
})
}
canonical := map[string]any{
"id": response.ID, "object": response.Object, "created": response.Created,
"model": response.Model, "choices": choices,
}
if len(response.Usage) > 0 && !bytes.Equal(bytes.TrimSpace(response.Usage), []byte("null")) {
canonical["usage"] = response.Usage
}
encoded, err := json.Marshal(canonical)
if err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
return encoded, nil
}
func normalizeSingleRequestResponsesResponse(body []byte) ([]byte, error) {
var response openAIResponsesBridgeResponse
if err := json.Unmarshal(body, &response); err != nil || strings.TrimSpace(response.ID) == "" {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)

View file

@ -1,6 +1,7 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -289,6 +290,31 @@ func TestSingleRequestProviderNormalizationConvertsResponsesResult(t *testing.T)
}
}
func TestSingleRequestProviderNormalizationCanonicalizesChatResult(t *testing.T) {
raw := []byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-model","service_tier":"default","system_fingerprint":"fp","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"{\"goal\":\"g\",\"steps\":[\"a\",\"b\"],\"verification\":[\"v\"]}","refusal":null,"annotations":[]}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)
body, err := normalizeSingleRequestProviderResponse(raw, edgeservice.RunDispatch{ProfileOperation: string(config.OperationChatCompletions)})
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte("service_tier")) || bytes.Contains(body, []byte("system_fingerprint")) || bytes.Contains(body, []byte("annotations")) || bytes.Contains(body, []byte("refusal")) {
t.Fatalf("provider bookkeeping leaked into canonical stage response: %s", body)
}
response, err := decodeSingleRequestChatResponse(body, edgeservice.RunDispatch{ProfileOperation: string(config.OperationChatCompletions)})
if err != nil {
t.Fatal(err)
}
if response.Output != `{"goal":"g","steps":["a","b"],"verification":["v"]}` {
t.Fatalf("output=%q", response.Output)
}
}
func TestSingleRequestProviderNormalizationRejectsChatRefusal(t *testing.T) {
raw := []byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-model","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":null,"refusal":"cannot comply"}}]}`)
if _, err := normalizeSingleRequestProviderResponse(raw, edgeservice.RunDispatch{ProfileOperation: string(config.OperationChatCompletions)}); !errors.Is(err, errProviderStageMalformed) {
t.Fatalf("err=%v, want malformed", err)
}
}
func TestSingleRequestProviderStageAcceptsDefaultSelectorProviderChoice(t *testing.T) {
request := providerRequest()
request.StageBinding.Dispatch.ProviderID = ""