diff --git a/agent-contract/index.md b/agent-contract/index.md index 8b328a3e..7242d449 100644 --- a/agent-contract/index.md +++ b/agent-contract/index.md @@ -12,7 +12,7 @@ | id | 읽는 조건 | 원본 경로 | path | |----|-----------|-----------|------| -| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, error envelope/SSE terminal error, `model` route, managed projection principal auth and slot-route binding, managed-versus-legacy provider credential selection, model-driven passthrough/normalized routing, provider-pool admission/unavailable error, safe credential-slot attribution, standard metadata, and provider-native extension fields such as `chat_template_kwargs` | `apps/edge/internal/openai/*`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/service/provider_tunnel.go`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` | +| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, error envelope/SSE terminal error, `model` route, managed projection principal auth and slot-route binding, managed-versus-legacy provider credential selection, model-driven passthrough/normalized routing, provider-pool admission/unavailable error, Gemini Chat thought-signature tool continuation, safe credential-slot attribution, standard metadata, and provider-native extension fields such as `chat_template_kwargs` | `apps/edge/internal/openai/*`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/service/provider_tunnel.go`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` | | `iop.anthropic-compatible-api` | Anthropic Messages API, count_tokens, models list, bearer or `X-Api-Key` principal auth, active managed projection auth and slot-route binding, `anthropic-version` routing, native Anthropic tunnel, Chat bridge, provider-pool-only admission, profile capability checks, managed-versus-legacy provider credentials, marked-preset single-request admission with Edge-owned internal Plan/Review template customization that leaves caller I/O unchanged, and current no-OpenAI-metric status | `apps/edge/internal/openai/anthropic_handler.go`, `apps/edge/internal/openai/anthropic_native.go`, `apps/edge/internal/openai/anthropic_bridge.go`, `apps/edge/internal/openai/anthropic_stream.go`, `apps/edge/internal/openai/anthropic_types.go`, `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/principal.go`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/openai/provider_tunnel.go`, `apps/edge/internal/openai/provider_model_rewrite.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`, `packages/go/singlerequesttemplate/template.go`, `packages/go/config/protocol_profile.go` | `agent-contract/outer/anthropic-compatible-api.md` | | `iop.gemini-compatible-api` | Gemini Developer API `streamGenerateContent`, route-qualified Gemini-native ingress, `x-goog-api-key` principal auth, `GOOGLE_GEMINI_BASE_URL`, official agy 1.1.12 API-key transport, Gemini function calls/thought signatures/SSE, and direct-versus-execution-preset binding | `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/principal.go`, `apps/edge/internal/openai/gemini_handler.go`, `apps/edge/internal/openai/gemini_bridge.go`, `apps/edge/internal/openai/gemini_types.go` | `agent-contract/outer/gemini-compatible-api.md` | | `iop.a2a-json-rpc-api` | A2A JSON-RPC API, `message/send`, `tasks/get`, `tasks/cancel`, A2A task state, agent card, `a2a.bearer_token`, Edge A2A input surface | `apps/edge/internal/input/a2a/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/a2a-json-rpc-api.md` | @@ -24,5 +24,5 @@ | `iop.edge-node-runtime-wire` | Edge-Node mTLS/protobuf, workload identity, Node initial/reconnect supervision, connection generation fencing, `RegisterRequest`, `NodeReadyRequest`, `RunRequest`, `RunEvent`, signed/sealed `CredentialLease`, `CredentialLeaseBinding`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, cancel, command, and config payload/refresh | `proto/iop/runtime.proto`, `apps/edge/internal/transport/*`, `apps/node/internal/transport/*`, `apps/node/internal/bootstrap/runtime_supervisor.go`, `apps/node/internal/node/tunnel_handler.go`, `apps/edge/internal/node/mapper.go`, `apps/node/internal/adapters/config_set.go` | `agent-contract/inner/edge-node-runtime-wire.md` | | `iop.control-plane-edge-wire` | Control Plane-Edge mTLS wire, authenticated workload identity, `EdgeHello*`, active `PrincipalProjection*` hello/refresh, authenticated `AcquireLease*`, status/command/event relay, Edge connection registry, and configured offline Node/provider snapshot | `proto/iop/control.proto`, `apps/control-plane/internal/wire/*`, `apps/control-plane/internal/credentialstore/projection.go`, `apps/control-plane/internal/credentiallease/*`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/controlplane/*` | `agent-contract/inner/control-plane-edge-wire.md` | | `iop.client-control-plane-wire` | Client-Control Plane `/client` WebSocket hello plus the adjacent server-authenticated credential HTTPS, principal bearer authorization, host-local bootstrap, and slot/route lifecycle boundary | `proto/iop/control.proto`, `apps/control-plane/internal/wire/client.go`, `apps/control-plane/cmd/control-plane/credential_http_handlers.go`, `apps/client/lib/iop_wire/*` | `agent-contract/inner/client-control-plane-wire.md` | -| `iop.edge-config-runtime-refresh` | Edge config schema, provider pool, protocol profiles, `credential_plane`, managed-versus-legacy auth exclusivity, required TLS/key material, `models[]`, `nodes[].providers[]`, `openai.model_routes`, and restart/applied refresh classification | `packages/go/config/edge_types.go`, `packages/go/config/provider_types.go`, `packages/go/config/load.go`, `packages/go/config/validate.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` | +| `iop.edge-config-runtime-refresh` | Edge config schema, provider pool, protocol profiles, effort/tool-call normalization, `credential_plane`, managed-versus-legacy auth exclusivity, required TLS/key material, `models[]`, `nodes[].providers[]`, `openai.model_routes`, and restart/applied refresh classification | `packages/go/config/edge_types.go`, `packages/go/config/provider_types.go`, `packages/go/config/load.go`, `packages/go/config/validate.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` | | `iop.execution-runtime` | Host-neutral provider lifecycle, execution events, typed failures, cancellation, usage, registry, tunnel, and closed provider commands | `packages/go/execution/*`, `apps/node/internal/node/runtime_bridge.go` | `agent-contract/inner/execution-runtime.md` | diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index 26141748..c105d427 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -45,6 +45,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c - `openai.principal_tokens[]`는 raw token을 저장하지 않고 hash/reference로 principal 매핑을 관리한다. 각 entry는 `token_ref` (non-empty, unique), `token_hash_sha256` (64-char hex, duplicate hash rejection), `principal_ref` (non-empty), optional `principal_alias` 필드를 갖는다. 여러 entry가 같은 `principal_ref`와 `principal_alias`를 공유할 수 있으며, 이때 `token_ref`가 앱/통합/용도별 사용량 분해 기준이 된다. tracked config에는 raw token을 저장하지 않고 hash/reference만 둔다. - `protocol_profiles` is the top-level map of custom profile overlays, keyed by stable profile id. Each `ProtocolProfileConf` can declare `base`, `driver`, `base_url`, an operation-path map, `auth`, `capabilities`, `model_mapping`, `normalization`, and `extensions`. A custom overlay extends one built-in or custom base; cycles, unknown bases, and invalid driver/operation/capability combinations are rejected during config normalization. - `normalization.effort[operation]` declares the provider wire, supported normalized grades, whether the operation preserves effort with caller tools, and whether it preserves an explicit thinking token budget. Every normalization operation must exist in the profile operation map. Grade keys use `none|low|medium|high|xhigh|max`; exact miss falls back only to the nearest declared lower key. A canonical mapped value above its source key is rejected so config cannot silently upgrade requested effort. This Edge-local selection fact is consumed before tunnel dispatch and is not serialized into a new caller or Edge-Node wire field. +- `normalization.tool_calls[operation].wire` declares operation-scoped provider tool-call metadata normalization independently from effort mapping. The only current value is `gemini_openai_chat`; the built-in `gemini` Chat Completions operation declares it, custom overlays inherit it, and unknown wires or undeclared operations fail config validation. This runtime-only fact is attached to the selected dispatch and is not a caller selector or a new Edge-Node field. - `nodes[].providers[].profile` selects a built-in or custom catalog entry. If the selector is empty, legacy provider-type normalization can select a compatibility profile; this is distinct from `base` inheritance. Normalization resolves the selection into the runtime-only `ProviderDefinition.RuntimeProfile` snapshot, which is not serialized back into YAML. The resolved snapshot is copied into the nested OpenAI-compatible adapter config, not into a per-request tunnel message. - `ConcreteProtocolProfile.MapModel(model)`은 provider의 model alias 정규화를 수행한다. provider가 model mapping을 정의하면 IOP external `model` key를 provider served target으로 변환한다. 매핑이 없으면 original model을 그대로 사용한다. - `ConcreteProtocolProfile.HasCapability(cap)`는 provider capability admission에 사용된다. closed vocabulary (`models`, `chat`, `messages`, `responses`, `streaming`, `tool_calling`, `count_tokens`)만 허용한다. diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 0fc9e403..c9ab626a 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -268,6 +268,7 @@ Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider - selected provider가 Ollama/native provider처럼 normalized execution을 요구하면 Edge는 normalized `RunRequest` path를 사용한다. 이 경로는 OpenAI-compatible 표면을 입력/출력 compatibility layer로 제공하되, backend 호출은 normalized adapter 계약을 따른다. - `metadata`는 경로 선택자가 아니다. Edge는 route 결정 뒤 인증 principal, usage/observability 등 IOP가 아는 bounded metadata key만 발췌한다. 이 발췌 정보는 provider body를 바꾸는 selector가 아니며, passthrough 응답 body에 IOP marker/event/envelope를 섞지 않는다. - Chat Completions 성공 응답의 top-level `model` echo가 provider-served model이면 caller가 요청한 IOP model alias로 정규화할 수 있다. reasoning/content/tool_calls 같은 provider payload field는 보존한다. +- Selected protocol profile의 `normalization.tool_calls.chat_completions.wire`가 `gemini_openai_chat`일 때만 Edge는 Gemini의 opaque `extra_content.google.thought_signature`를 표준 `tool_calls[].id` 안에 캡슐화하고 provider extension field를 caller 응답에서 제거한다. Caller는 받은 tool-call id를 assistant history와 tool result의 `tool_call_id`에 변경 없이 replay해야 한다. 다음 요청에서 Edge는 원래 id와 signature를 복원해 Gemini provider에 전달한다. 다른 profile, signature 없는 Gemini 응답, tool call 없는 응답에는 적용하지 않으며 caller/SDK 이름으로 분기하지 않는다. IOP prefix를 가진 malformed id와 충돌하는 signature는 provider dispatch 전에 거부한다. IOP 확장 think 제어 field: diff --git a/agent-ops/.version b/agent-ops/.version index 99380987..837aff75 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.201 +1.1.202 diff --git a/agent-ops/skills/common/update-roadmap/SKILL.md b/agent-ops/skills/common/update-roadmap/SKILL.md index 5147b221..377e2e19 100644 --- a/agent-ops/skills/common/update-roadmap/SKILL.md +++ b/agent-ops/skills/common/update-roadmap/SKILL.md @@ -250,6 +250,7 @@ agent-roadmap/ | 작업 컨텍스트/TODO | 에이전트가 확정할 수 없는 결정 또는 조사/확인이 먼저 필요해 기능 Task로 확정하기 어렵다 | - 먼저 요청 내용의 규모를 판정한다. 배치 위치를 찾기 전에 `phase`, `milestone`, `epic`, `task`, `subtask`, `context` 중 가장 작은 충분한 단위를 고른다. +- Milestone에는 목표 달성에 필요한 최소 capability만 둔다. 완료 조건에 필수라는 근거가 없는 검증 도구, 자동화, 범용화는 범위 제외나 후속 Milestone으로 둔다. - 요청이 방향성, 문제의식, 컨셉, 운영 원칙 수준이고 기능 Task나 실행 범위가 아직 부족하면 새 항목의 상태는 `[스케치]`로 둔다. - `[스케치]` Phase/Milestone을 만들 때는 `승격 조건`에 `[계획]`으로 전환하기 위해 필요한 정의, 결정, 경계, 후속 구현 Milestone 후보를 체크리스트로 남긴다. - 가장 작은 충분한 단위 원칙을 따른다. 애매하면 새 Phase나 새 Milestone으로 키우지 말고, 기존 Milestone의 Epic/Task에 넣을 수 있는지 먼저 확인한다. diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index 24582cdd..f2f1115f 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -17,9 +17,14 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 ## 구현 잠금 - 상태: 해제 -- SDD: 불필요 -- SDD 문서: 없음 -- SDD 사유: 기존 공개 호출 경로를 임시 workspace에서 수동 확인하는 test-only 작업이며 API, wire, config, schema, lifecycle 또는 retry 계약을 새로 만들지 않는다. +- SDD: 필요 +- SDD 문서: [SDD.md](../../../sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md) +- 잠금 해제 조건: + - [x] SDD 잠금이 해제되어 있다 + - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다 + - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다 + - [x] Evidence Map이 완료 시 `complete.log` 첫 줄의 `milestone-task` id별 집계와 최종 검증 evidence로 검증 가능하게 연결되어 있다 +- SDD 사유: OpenCode direct 실측이 Gemini Chat thought-signature 왕복 결함을 재현했고, 이를 caller 예외가 아닌 operation-scoped protocol profile config와 외부 Chat tool-call 계약으로 고정해야 한다. - 결정 필요: 없음 ## 범위 @@ -27,11 +32,11 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - 아래 9개 기존 벤치 대상 조합을 순서와 무관하게 한 경로씩 직접 실행한다. - Claude Code → Claude direct - Claude Code → Gemini direct - - agy → Gemini direct + - OpenCode → Gemini direct - Claude Code → GPT direct - Codex → GPT direct - Claude Code → Gemini execution preset - - agy → Gemini execution preset + - OpenCode → Gemini execution preset - Claude Code → GPT execution preset - Codex → GPT execution preset - 모든 경로에 같은 구현 요구를 사용한다: 외부 asset과 JavaScript 없이 exact marker가 있는 단일 `index.html`을 구현하고 종료한다. @@ -73,7 +78,7 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - 실행 방식: 기존 공식 caller 명령을 한 번씩 직접 실행한다. 공통화가 필요해 보여도 이 Milestone에서는 script로 승격하지 않는다. - TLS 환경 경계: 개발 Edge용 사설 CA는 Edge leaf 인증서가 아니라 managed CA bundle을 해당 벤치 caller process에만 전달한다. Codex에는 공식 변수 `CODEX_CA_CERTIFICATE`, Node 기반 caller에는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용하며 Codex/IDE 시작 환경이나 셸 전역에 `export`하지 않는다. 그렇지 않으면 공개 TLS 연결에도 같은 CA override가 적용될 수 있다. - evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` -- 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177, agy 1.1.12, Codex 0.146.0을 확인했고, 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. +- 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177과 Codex 0.146.0을 확인했고, 현재 workspace의 OpenCode 1.18.3을 별도 사용자 설정 변경 없이 command-scoped provider config로 준비했다. 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. - 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했으나 caller-neutral operation normalization 반영 뒤 9초 안에 caller success와 `index.html` marker 1회를 확인했다. Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. -- 추가 분리 결과: agy → Gemini direct는 누락된 caller `modelProvider=gemini`를 보정한 뒤 공식 URL-encoded model label을 IOP Gemini path parser가 거부하는 제품 결함까지 좁혔다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset은 모두 provider 호출 뒤 Plan `validation`으로 닫혔고, 원인은 `default` selector가 비워 둔 provider ID와 pool이 선택한 실제 provider ID를 사후에 무조건 비교한 IOP 결함으로 확인했다. explicit selector와 profile/model/credential/path fence는 유지하면서 default selector의 pool 선택만 인정하도록 수정했고, private Plan/Work/Review에도 profile별 Chat/Responses 선택과 nearest-lower effort mapping을 적용한 focused regression 및 Edge 전체 테스트가 통과했다. 개발 런타임에는 아직 배포되지 않았다. +- 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀가 통과했다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. - 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) diff --git a/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md b/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md new file mode 100644 index 00000000..889f442f --- /dev/null +++ b/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md @@ -0,0 +1,91 @@ +# SDD: [bench-route-01] 벤치 경로 최소 HTML 스모크 + +## 위치 + +- Milestone: [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) +- Phase: [PHASE.md](../../../phase/knowledge-tool-optimization-extension/PHASE.md) + +## 상태 + +[승인됨] + +## SDD 잠금 + +- 상태: 해제 +- 사용자 리뷰: 없음 +- 잠금 항목: 없음 + +## 문제 / 비목표 + +- 문제: OpenCode의 표준 Chat tool continuation에서 Gemini 전용 `thought_signature`가 유실되어 두 번째 호출이 HTTP 400으로 실패한다. caller별 보정 없이 선택된 provider profile이 이 메타데이터 왕복을 소유해야 한다. +- 비목표: + - benchmark runner, retry, manifest, state store를 만들지 않는다. + - GPT, Claude, ornith-fast 등 non-Gemini provider의 tool-call wire를 바꾸지 않는다. + - OpenCode 또는 다른 caller 이름을 routing/normalization selector로 쓰지 않는다. + +## Source of Truth + +| 영역 | 기준 | 메모 | +|------|------|------| +| Roadmap | [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) | 9개 얇은 경로와 실패 경로만 재검증하는 범위 | +| Code | `packages/go/config/protocol_profile.go`, `apps/edge/internal/openai/provider_model_rewrite.go` | operation-scoped tool-call wire와 요청·응답 왕복 정규화 | +| External Provider | Gemini OpenAI-compatible Chat | Tool call을 이어갈 때 opaque thought signature를 요구한다. | +| User Decision | 없음 | agy를 제외하고 같은 두 자리에 OpenCode를 넣는 범위가 확정됐다. | + +## State Machine + +| 상태 | 진입 조건 | 다음 상태 | 근거 | +|------|-----------|-----------|------| +| provider tool response | selected profile의 Chat tool-call wire가 `gemini_openai_chat`이고 signature가 있다 | caller replay | Edge가 signature를 opaque tool-call id로 캡슐화한다. | +| caller replay | caller가 opaque id를 assistant/tool history에 변경 없이 보낸다 | provider continuation | Edge가 원래 id와 signature를 복원한다. | +| non-Gemini/no-signature | 다른 wire이거나 signature가 없다 | passthrough | 기존 요청·응답을 변경하지 않는다. | +| malformed opaque id | IOP prefix가 있지만 payload가 유효하지 않다 | fail-closed | provider dispatch 전에 요청을 거부한다. | + +## Interface Contract + +- 계약 원문: [OpenAI-Compatible API](../../../../agent-contract/outer/openai-compatible-api.md), [Edge Config And Runtime Refresh](../../../../agent-contract/inner/edge-config-runtime-refresh.md) +- 입력: + - `normalization.tool_calls[operation].wire`: provider tool-call metadata normalization 선택자 + - `tool_calls[].id` / `tool_call_id`: caller가 왕복 보존하는 표준 id 표면 +- 출력: + - Gemini signature가 있는 tool call은 signature를 별도 extension으로 노출하지 않고 opaque id를 반환한다. +- 금지: + - caller/SDK 이름으로 분기하지 않는다. + - effort mapping을 tool-call metadata selector로 재사용하지 않는다. + - non-Gemini profile 응답을 변환하지 않는다. + +## Acceptance Scenarios + +| ID | Milestone Task | Given | When | Then | +|----|----------------|-------|------|------| +| S01 | `minimal-html-calls` | OpenCode가 Gemini direct route에서 tool을 호출한다 | tool result를 같은 Chat history로 재전송한다 | Edge가 signature를 복원하고 provider continuation이 성공한다. | +| S02 | `failed-path-fixes` | Gemini와 non-Gemini protocol profile | 동일 signature 모양의 응답 및 malformed opaque id를 처리한다 | Gemini wire만 왕복하고 non-Gemini는 무변경, malformed는 fail-closed다. | +| S03 | `thin-bench-handoff` | direct/hybrid를 포함한 9개 얇은 경로 결과 | 결과표를 갱신한다 | 호출 가능 여부와 남은 소유 경계만 남고 새 harness는 없다. | + +## Evidence Map + +| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 | +|----------|-------------------|------------------|---------------------------| +| S01 | `provider_model_rewrite_test.go` HTTP two-turn regression과 OpenCode direct 실측 | `agent-task/m-benchmark-route-minimal-html-smoke/...` | `minimal-html-calls` evidence에 tool continuation과 marker 결과 | +| S02 | config/service/openai package tests | `agent-task/m-benchmark-route-minimal-html-smoke/...` | `failed-path-fixes` evidence에 Gemini-only/no-op/fail-closed 결과 | +| S03 | `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` | `agent-task/m-benchmark-route-minimal-html-smoke/...` | `thin-bench-handoff` evidence에 9개 결과와 남은 blocker | + +## Cross-repo Dependencies + +- 없음 + +## Drift Check + +- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다. +- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다. +- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다. +- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다. + +## 사용자 리뷰 이력 + +- 없음 + +## 작업 컨텍스트 + +- 표준선: provider normalization은 선택된 concrete protocol profile의 operation별 wire를 기준으로 하며 caller identity와 분리한다. +- 후속 SDD: 없음 diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index d135c98b..75a97f64 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -108,6 +108,12 @@ source_evidence: - type: code path: apps/edge/internal/openai/provider_tunnel.go notes: Shared provider tunnel auth headers and passthrough + - type: code + path: apps/edge/internal/openai/provider_model_rewrite.go + notes: Selected profile의 operation별 tool-call wire에 따른 Gemini thought-signature 왕복 정규화 + - type: test + path: apps/edge/internal/openai/provider_model_rewrite_test.go + notes: Gemini-only non-stream/stream/HTTP tool continuation과 non-Gemini 무변경 회귀 검증 - type: code path: packages/go/config/protocol_profile.go notes: ConcreteProtocolProfile, operation capability admission, model/effort normalization @@ -221,6 +227,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | model-driven response path | request `model`이 가리키는 provider capability가 provider raw tunnel 또는 normalized RunEvent path를 결정한다. caller metadata는 route나 response shape를 선택하지 않는다. OpenAI와 Anthropic ingress는 같은 model catalog와 provider-pool dispatch를 공유한다. | | provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP 확장 envelope를 섞지 않는다. | | provider-native field 보존 | provider raw tunnel route는 `model` served target rewrite와 auth/header 처리 외에 selected provider가 지원하는 표준 field와 provider extension field를 보존한다. OpenAI route는 OpenAI-compatible field를, Anthropic native route는 Anthropic field를 보존한다. | +| Gemini Chat tool-call normalization | Selected profile의 operation별 tool-call wire가 `gemini_openai_chat`이면 Gemini `thought_signature`를 opaque 표준 tool-call id로 캡슐화해 caller가 보존할 수 있게 하고, 다음 tool result 요청에서 원래 id/signature를 복원한다. Effort mapping과 독립적이며 다른 provider와 caller identity에는 적용하지 않는다. | | OpenAI usage metering | OpenAI handlers emit one request terminal and canonical token/reasoning series for each actual provider attempt that reports usage. Anthropic handlers do not currently emit this metric series; native tunnel `USAGE` frames are ignored. | | safe credential attribution | Managed OpenAI attempt metrics include only stable `credential_slot_ref` and immutable `credential_revision`; request terminals omit them, and slot alias, lease id, raw credential/key, target URL, request IDs, and payload content are forbidden labels. | | reasoning observation metric | provider가 reasoning token을 보고하지 않고 reasoning text만 관측되면 관측 횟수와 character count 보조 metric을 emit하고, 별도 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`)로 `estimation_method="chars_div_4"` 추정을 제공한다. | @@ -350,6 +357,7 @@ sequenceDiagram ## 변경 기록 +- 2026-08-14: Added operation-scoped `normalization.tool_calls` and Gemini-only Chat thought-signature round trips across standard OpenAI-compatible callers, including non-stream, SSE, and recovery-selected dispatches. Effort mapping and caller identity remain independent (`packages/go/config/protocol_profile.go`, `apps/edge/internal/openai/provider_model_rewrite.go`). - 2026-08-13: Added official agy 1.1.12 model-role `functionResponse` continuation support while retaining fail-closed rejection for mixed assistant/tool-response content (`apps/edge/internal/openai/gemini_handler.go`). - 2026-08-12: Admitted Claude Code's `advisor-tool-2026-03-01` beta as a consumed compatibility marker for both direct and marked-preset Messages ingress. It grants no internal capability and is not forwarded through the Chat bridge (`apps/edge/internal/openai/anthropic_types.go`). - 2026-08-12: Replaced free-form PlanMD generation with a stage-owned strict `goal`/`steps`/`verification` JSON response and deterministic Edge rendering of the frozen Plan template. Internal artifact customization and all caller-visible Messages schemas remain unchanged (`apps/edge/internal/openai/single_request_plan_stage.go`, `packages/go/singlerequesttemplate/template.go`). diff --git a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md index 1c1a0d58..64f10143 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -13,7 +13,7 @@ | 항목 | 결과 | |---|---| | Claude Code | 실제 원격 실행기 2.1.177 확인 | -| agy | 1.1.12 확인 | +| OpenCode | 현재 workspace 1.18.3 확인; command-scoped provider config 사용 | | Codex | 실제 원격 실행기 0.146.0 확인 | | managed CA | live Edge CA bundle 확인 | | principal token | 원격 SOPS의 기존 두 token 사용; 별도 benchmark token 미사용 | @@ -26,15 +26,15 @@ |---|---|---|---| | Claude Code → Claude direct | 통과 | 없음 | 기존 성공 | | Claude Code → Gemini direct | 통과 | 없음 | 기존 성공 | -| agy → Gemini direct | 1초, caller login 요구 | parser 배포 뒤 19-22초, caller `SUCCESS` 2회, 파일 없음 | IOP parser 결함 해소; agy가 trusted workspace에서도 파일 도구를 호출하지 않은 caller/tool 이슈 분리 | +| OpenCode → Gemini direct | 6초, 첫 `glob` tool 성공 뒤 두 번째 provider 호출 HTTP 400 | provider normalization 구현·배포 뒤 재검증 대기 | 선택된 Gemini Chat profile의 `thought_signature`를 표준 caller history로 왕복하지 못한 IOP 결함; agy는 구현 Agent 부적합으로 대상 제외 | | 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 파일 없음 | 새 배포에서 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 수정 배포 대기 | 선행 결함 | +| OpenCode → Gemini execution preset | 미실행 | provider normalization 배포 뒤 실행 | direct 경로의 Gemini tool continuation 선행 검증 | | Claude Code → GPT execution preset | 미실행 | selector/Chat 응답 정규화 배포 뒤 최신 Claude 요청이 provider 전 95ms에 `messages[1].role` 검증 거절 | 실제 caller가 `user → system`과 mid-conversation-system beta를 보냄; IOP가 beta만 선언하고 ingress/lineage/Chat/Responses normalize를 구현하지 않은 별도 제품 결함 확인·국소 수정 | | 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 결함을 재현했다. +추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. OpenCode direct는 command-scoped `OPENCODE_CONFIG_CONTENT`와 원격 SOPS의 기존 token을 사용해 user config를 변경하지 않았다. 첫 `glob` tool까지 성공했으나 caller가 Gemini provider extension을 표준 Chat history에 보존하지 않아 다음 요청이 `thought_signature` 누락 HTTP 400으로 실패했다. 이는 OpenCode 전용 문제가 아니라 selected Gemini Chat provider normalization 누락으로 분류했다. 두 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로 수렴시키는 국소 회귀가 통과했다. @@ -44,6 +44,6 @@ ## 재개 조건 -Anthropic mid-conversation system normalization을 병합·배포한 뒤 Claude Code → GPT preset만 1회 재검증한다. 운영 release capacity gate는 인증 projection의 `ornith:35b` selector가 기대한 OneX가 아니라 RTX로 바뀐 별도 운영 라우트 불일치를 먼저 해소해야 한다. 이를 통과시키려고 route나 capacity를 임의 변경하지 않는다. 그 뒤 선행 결함이 해소된 agy preset을 1회 수행한다. Claude Code GPT direct와 이미 성공한 경로는 반복하지 않는다. +Gemini Chat tool-call normalization을 dev 런타임에 반영한 뒤 OpenCode → Gemini direct를 한 번 재검증하고, 통과하면 OpenCode → Gemini execution preset을 한 번 실행한다. 이미 성공한 경로는 반복하지 않는다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. 성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다. diff --git a/apps/edge/internal/openai/anthropic_bridge.go b/apps/edge/internal/openai/anthropic_bridge.go index 6b46d2d5..b379ac8e 100644 --- a/apps/edge/internal/openai/anthropic_bridge.go +++ b/apps/edge/internal/openai/anthropic_bridge.go @@ -10,7 +10,7 @@ import ( "iop/packages/go/config" ) -const anthropicBridgeToolIDPrefix = "iop_gts_" +const geminiThoughtSignatureToolIDPrefix = "iop_gts_" type openAIChatToolExtraContent struct { Google *struct { @@ -390,7 +390,7 @@ func convertChatResponseToAnthropic(body []byte, requestModel string) (anthropic }, nil } -type anthropicBridgeToolID struct { +type geminiThoughtSignatureToolID struct { ID string `json:"id"` ThoughtSignature string `json:"thought_signature"` } @@ -399,26 +399,41 @@ func encodeAnthropicBridgeToolID(id string, extra openAIChatToolExtraContent) st if extra.Google == nil || extra.Google.ThoughtSignature == "" { return id } - payload, err := json.Marshal(anthropicBridgeToolID{ID: id, ThoughtSignature: extra.Google.ThoughtSignature}) + return encodeGeminiThoughtSignatureToolID(id, extra.Google.ThoughtSignature) +} + +func encodeGeminiThoughtSignatureToolID(id, thoughtSignature string) string { + if id == "" || thoughtSignature == "" { + return id + } + payload, err := json.Marshal(geminiThoughtSignatureToolID{ID: id, ThoughtSignature: thoughtSignature}) if err != nil { return id } - return anthropicBridgeToolIDPrefix + base64.RawURLEncoding.EncodeToString(payload) + return geminiThoughtSignatureToolIDPrefix + base64.RawURLEncoding.EncodeToString(payload) } func decodeAnthropicBridgeToolID(id string) (string, string, bool) { - if !strings.HasPrefix(id, anthropicBridgeToolIDPrefix) { - return id, "", false - } - payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, anthropicBridgeToolIDPrefix)) + decodedID, signature, encoded, err := decodeGeminiThoughtSignatureToolID(id) if err != nil { return id, "", false } - var decoded anthropicBridgeToolID - if err := json.Unmarshal(payload, &decoded); err != nil || decoded.ID == "" || decoded.ThoughtSignature == "" { - return id, "", false + return decodedID, signature, encoded +} + +func decodeGeminiThoughtSignatureToolID(id string) (string, string, bool, error) { + if !strings.HasPrefix(id, geminiThoughtSignatureToolIDPrefix) { + return id, "", false, nil } - return decoded.ID, decoded.ThoughtSignature, true + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, geminiThoughtSignatureToolIDPrefix)) + if err != nil { + return id, "", false, fmt.Errorf("decode Gemini thought-signature tool id: %w", err) + } + var decoded geminiThoughtSignatureToolID + if err := json.Unmarshal(payload, &decoded); err != nil || decoded.ID == "" || decoded.ThoughtSignature == "" { + return id, "", false, fmt.Errorf("decode Gemini thought-signature tool id: invalid payload") + } + return decoded.ID, decoded.ThoughtSignature, true, nil } func openAIChatThoughtSignature(signature string) map[string]any { diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 12024bfd..86747ccb 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -381,7 +381,14 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch tunnelReq.Headers = headers return tunnelReq, nil } - poolReq.PrepareProtocolTunnel = s.protocolTunnelPreparer(r, config.OperationChatCompletions) + baseProtocolPreparer := s.protocolTunnelPreparer(r, config.OperationChatCompletions) + poolReq.PrepareProtocolTunnel = func(tunnelReq edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) { + prepared, err := baseProtocolPreparer(tunnelReq, selected) + if err != nil { + return tunnelReq, err + } + return prepareProviderChatToolCallNormalization(prepared, selected) + } // strict-output output policy only applies to normalized dispatch, // not to raw tunnel passthrough (SDD D02). The caller's raw body is the diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 057cf72c..f771afbb 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -6,6 +6,9 @@ import ( "fmt" "strings" "sync" + + edgeservice "iop/apps/edge/internal/service" + "iop/packages/go/config" ) type openAIProviderBodyBuilder struct { @@ -67,23 +70,43 @@ func (b *openAIProviderBodyBuilder) Close() { } type providerModelRewriter struct { - streaming bool - model string - pending []byte + streaming bool + model string + toolCallWire string + pending []byte } func newProviderModelRewriter(streaming bool, model string) *providerModelRewriter { + return newProviderModelRewriterWithToolCallWire(streaming, model, "") +} + +func newProviderModelRewriterForDispatch(streaming bool, model string, dispatch edgeservice.RunDispatch) *providerModelRewriter { + return newProviderModelRewriterWithToolCallWire(streaming, model, dispatch.ProfileToolCallWire) +} + +func newProviderModelRewriterWithToolCallWire(streaming bool, model, toolCallWire string) *providerModelRewriter { model = strings.TrimSpace(model) - if model == "" { + toolCallWire = strings.TrimSpace(toolCallWire) + if model == "" && toolCallWire == "" { return nil } - return &providerModelRewriter{streaming: streaming, model: model} + return &providerModelRewriter{streaming: streaming, model: model, toolCallWire: toolCallWire} +} + +func (r *providerModelRewriter) setToolCallWire(toolCallWire string) { + if r != nil { + r.toolCallWire = strings.TrimSpace(toolCallWire) + } } func (r *providerModelRewriter) AppendStream(chunk []byte) []byte { - if r == nil || !r.streaming || len(chunk) == 0 { + if r == nil || len(chunk) == 0 { return chunk } + if !r.streaming { + r.pending = append(r.pending, chunk...) + return nil + } r.pending = append(r.pending, chunk...) var out bytes.Buffer for { @@ -92,7 +115,7 @@ func (r *providerModelRewriter) AppendStream(chunk []byte) []byte { break } line := r.pending[:idx+1] - out.Write(rewriteProviderSSEModelLine(line, r.model)) + out.Write(rewriteProviderSSELine(line, r.model, r.toolCallWire)) r.pending = r.pending[idx+1:] } return out.Bytes() @@ -104,17 +127,24 @@ func (r *providerModelRewriter) FlushStream() []byte { } pending := r.pending r.pending = nil - return rewriteProviderSSEModelLine(pending, r.model) + if !r.streaming { + return r.RewriteComplete(pending) + } + return rewriteProviderSSELine(pending, r.model, r.toolCallWire) } func (r *providerModelRewriter) RewriteComplete(body []byte) []byte { if r == nil || len(body) == 0 { return body } - return rewriteProviderJSONModel(body, r.model) + return rewriteProviderJSONResponse(body, r.model, r.toolCallWire) } func rewriteProviderSSEModelLine(line []byte, model string) []byte { + return rewriteProviderSSELine(line, model, "") +} + +func rewriteProviderSSELine(line []byte, model, toolCallWire string) []byte { body, ending := splitLineEnding(line) prefix, payload, ok := bytes.Cut(body, []byte(":")) if !ok || strings.TrimSpace(string(prefix)) != "data" { @@ -124,7 +154,7 @@ func rewriteProviderSSEModelLine(line []byte, model string) []byte { if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) { return line } - rewritten := rewriteProviderJSONModel(payload, model) + rewritten := rewriteProviderJSONResponse(payload, model, toolCallWire) if bytes.Equal(rewritten, payload) { return line } @@ -135,6 +165,180 @@ func rewriteProviderSSEModelLine(line []byte, model string) []byte { return out } +func rewriteProviderJSONResponse(body []byte, model, toolCallWire string) []byte { + rewritten := rewriteProviderJSONModel(body, model) + if toolCallWire != config.ProtocolToolCallWireGeminiChat { + return rewritten + } + return normalizeGeminiChatProviderResponse(rewritten) +} + +func normalizeGeminiChatProviderResponse(body []byte) []byte { + if !bytes.Contains(body, []byte(`"thought_signature"`)) { + return body + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var response map[string]any + if err := decoder.Decode(&response); err != nil { + return body + } + changed := false + for _, rawChoice := range anySlice(response["choices"]) { + choice, ok := rawChoice.(map[string]any) + if !ok { + continue + } + for _, messageKey := range []string{"message", "delta"} { + message, ok := choice[messageKey].(map[string]any) + if !ok { + continue + } + for _, rawCall := range anySlice(message["tool_calls"]) { + call, ok := rawCall.(map[string]any) + if !ok { + continue + } + id, idOK := call["id"].(string) + extra, extraOK := call["extra_content"].(map[string]any) + if !idOK || id == "" || !extraOK { + continue + } + google, googleOK := extra["google"].(map[string]any) + if !googleOK { + continue + } + signature, signatureOK := google["thought_signature"].(string) + if !signatureOK || signature == "" { + continue + } + call["id"] = encodeGeminiThoughtSignatureToolID(id, signature) + delete(google, "thought_signature") + if len(google) == 0 { + delete(extra, "google") + } + if len(extra) == 0 { + delete(call, "extra_content") + } + changed = true + } + } + } + if !changed { + return body + } + encoded, err := json.Marshal(response) + if err != nil { + return body + } + return encoded +} + +func normalizeGeminiChatProviderRequest(body []byte) ([]byte, error) { + if !bytes.Contains(body, []byte(geminiThoughtSignatureToolIDPrefix)) { + return body, nil + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var request map[string]any + if err := decoder.Decode(&request); err != nil { + return nil, fmt.Errorf("decode Gemini Chat provider request: %w", err) + } + changed := false + for _, rawMessage := range anySlice(request["messages"]) { + message, ok := rawMessage.(map[string]any) + if !ok { + continue + } + for _, rawCall := range anySlice(message["tool_calls"]) { + call, ok := rawCall.(map[string]any) + if !ok { + continue + } + encodedID, ok := call["id"].(string) + if !ok { + continue + } + id, signature, encoded, err := decodeGeminiThoughtSignatureToolID(encodedID) + if err != nil { + return nil, err + } + if !encoded { + continue + } + if err := restoreGeminiThoughtSignature(call, signature); err != nil { + return nil, err + } + call["id"] = id + changed = true + } + if encodedID, ok := message["tool_call_id"].(string); ok { + id, _, encoded, err := decodeGeminiThoughtSignatureToolID(encodedID) + if err != nil { + return nil, err + } + if encoded { + message["tool_call_id"] = id + changed = true + } + } + } + if !changed { + return body, nil + } + encoded, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("encode Gemini Chat provider request: %w", err) + } + return encoded, nil +} + +func restoreGeminiThoughtSignature(call map[string]any, signature string) error { + extra, ok := call["extra_content"].(map[string]any) + if !ok { + if call["extra_content"] != nil { + return fmt.Errorf("restore Gemini thought signature: extra_content is not an object") + } + extra = make(map[string]any) + call["extra_content"] = extra + } + google, ok := extra["google"].(map[string]any) + if !ok { + if extra["google"] != nil { + return fmt.Errorf("restore Gemini thought signature: extra_content.google is not an object") + } + google = make(map[string]any) + extra["google"] = google + } + if existing, exists := google["thought_signature"]; exists && existing != signature { + return fmt.Errorf("restore Gemini thought signature: conflicting signature") + } + google["thought_signature"] = signature + return nil +} + +func prepareProviderChatToolCallNormalization(tunnel edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) { + if selected.ProtocolProfile == nil { + return tunnel, nil + } + mapping, ok := selected.ProtocolProfile.ToolCallMapping(config.OperationChatCompletions) + if !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat { + return tunnel, nil + } + build := tunnel.BuildBody + if build == nil { + return tunnel, fmt.Errorf("Gemini Chat provider body builder is unavailable") + } + tunnel.BuildBody = func(target string) ([]byte, error) { + body, err := build(target) + if err != nil { + return nil, err + } + return normalizeGeminiChatProviderRequest(body) + } + return tunnel, nil +} + func splitLineEnding(line []byte) ([]byte, []byte) { if len(line) == 0 || line[len(line)-1] != '\n' { return line, nil diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go new file mode 100644 index 00000000..e8d59578 --- /dev/null +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -0,0 +1,172 @@ +package openai + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + edgeservice "iop/apps/edge/internal/service" + "iop/packages/go/config" +) + +func TestGeminiChatProviderThoughtSignatureRoundTrip(t *testing.T) { + response := []byte(`{"id":"chat-1","model":"served","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"},"extra_content":{"google":{"thought_signature":"opaque-signature"}}}]},"finish_reason":"tool_calls"}]}`) + rewritten := rewriteProviderJSONResponse(response, "public-gemini", config.ProtocolToolCallWireGeminiChat) + + var decoded map[string]any + if err := json.Unmarshal(rewritten, &decoded); err != nil { + t.Fatal(err) + } + choice := anySlice(decoded["choices"])[0].(map[string]any) + message := choice["message"].(map[string]any) + call := anySlice(message["tool_calls"])[0].(map[string]any) + encodedID := call["id"].(string) + if !strings.HasPrefix(encodedID, geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("thought signature was not encoded in tool id: %+v", call) + } + if _, exists := call["extra_content"]; exists { + t.Fatalf("Gemini extension leaked after normalization: %+v", call) + } + if decoded["model"] != "public-gemini" { + t.Fatalf("model echo was not preserved through normalization: %+v", decoded) + } + + request, err := normalizeGeminiChatProviderRequest([]byte(`{"model":"served","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"` + encodedID + `","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"}}]},{"role":"tool","tool_call_id":"` + encodedID + `","content":"[]"}]}`)) + if err != nil { + t.Fatal(err) + } + var replay map[string]any + if err := json.Unmarshal(request, &replay); err != nil { + t.Fatal(err) + } + messages := anySlice(replay["messages"]) + replayedCall := anySlice(messages[0].(map[string]any)["tool_calls"])[0].(map[string]any) + if replayedCall["id"] != "call-1" || messages[1].(map[string]any)["tool_call_id"] != "call-1" { + t.Fatalf("original tool ids were not restored: %+v", messages) + } + extra := replayedCall["extra_content"].(map[string]any) + google := extra["google"].(map[string]any) + if google["thought_signature"] != "opaque-signature" { + t.Fatalf("thought signature was not restored: %+v", replayedCall) + } +} + +func TestProviderThoughtSignatureNormalizationIsGeminiProfileOnly(t *testing.T) { + response := []byte(`{"model":"served","choices":[{"message":{"tool_calls":[{"id":"call-1","extra_content":{"google":{"thought_signature":"opaque"}}}]}}]}`) + if got := rewriteProviderJSONResponse(response, "public", ""); !strings.Contains(string(got), `"id":"call-1"`) || !strings.Contains(string(got), `"thought_signature":"opaque"`) { + t.Fatalf("non-Gemini response was normalized: %s", got) + } + + profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatal(err) + } + baseBody := []byte(`{"model":"served","messages":[{"role":"user","content":"hi"}]}`) + tunnel := edgeservice.SubmitProviderTunnelRequest{BuildBody: func(string) ([]byte, error) { return baseBody, nil }} + prepared, err := prepareProviderChatToolCallNormalization(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile}) + if err != nil { + t.Fatal(err) + } + got, err := prepared.BuildBody("served") + if err != nil || string(got) != string(baseBody) { + t.Fatalf("OpenAI profile body changed: %s err=%v", got, err) + } +} + +func TestGeminiChatProviderStreamingThoughtSignatureNormalization(t *testing.T) { + line := []byte("data: {\"model\":\"served\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"glob\",\"arguments\":\"{}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"opaque\"}}}]}}]}\n\n") + rewriter := newProviderModelRewriterWithToolCallWire(true, "public", config.ProtocolToolCallWireGeminiChat) + got := append(rewriter.AppendStream(line), rewriter.FlushStream()...) + if strings.Contains(string(got), "thought_signature") || !strings.Contains(string(got), geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("streaming signature was not normalized: %s", got) + } +} + +func TestGeminiChatProviderRejectsMalformedOpaqueToolID(t *testing.T) { + _, err := normalizeGeminiChatProviderRequest([]byte(`{"messages":[{"role":"assistant","tool_calls":[{"id":"iop_gts_not-base64"}]}]}`)) + if err == nil { + t.Fatal("malformed IOP Gemini tool id must fail closed") + } +} + +func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { + var providerRequests []map[string]any + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + t.Errorf("decode provider request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + providerRequests = append(providerRequests, request) + w.Header().Set("Content-Type", "application/json") + if len(providerRequests) == 1 { + _, _ = w.Write([]byte(`{"id":"chat-1","model":"served-gemini","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"},"extra_content":{"google":{"thought_signature":"opaque-signature"}}}]},"finish_reason":"tool_calls"}]}`)) + return + } + _, _ = w.Write([]byte(`{"id":"chat-2","model":"served-gemini","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) + })) + defer provider.Close() + + profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatal(err) + } + fake := &providerFakeRunService{ + poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), + tunnelProviderURL: provider.URL, + tunnelServedTarget: "served-gemini", + poolSelectedCandidate: edgeservice.ProviderPoolCandidate{ + ActualModel: "served-gemini", ProviderID: "gemini-provider", + ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProfileID: profile.ID, + ProfileDriver: string(profile.Driver), ProfileCapabilities: append([]string(nil), profile.Capabilities...), + ProtocolProfile: &profile, + }, + } + srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) + srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "gemini-route", Providers: map[string]string{"gemini-provider": "served-gemini"}}}) + + first := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gemini-route","messages":[{"role":"user","content":"list files"}],"tools":[{"type":"function","function":{"name":"glob","parameters":{"type":"object"}}}]}`)) + firstResponse := httptest.NewRecorder() + srv.handleChatCompletions(firstResponse, first) + if firstResponse.Code != http.StatusOK { + t.Fatalf("first status=%d body=%s", firstResponse.Code, firstResponse.Body.String()) + } + if got := fake.lastTunnelHandle.Dispatch().ProfileToolCallWire; got != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("selected dispatch tool-call wire=%q", got) + } + var firstBody map[string]any + if err := json.Unmarshal(firstResponse.Body.Bytes(), &firstBody); err != nil { + t.Fatal(err) + } + firstChoice := anySlice(firstBody["choices"])[0].(map[string]any) + firstMessage := firstChoice["message"].(map[string]any) + encodedID := anySlice(firstMessage["tool_calls"])[0].(map[string]any)["id"].(string) + if !strings.HasPrefix(encodedID, geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("caller did not receive opaque normalized id: %s", firstResponse.Body.String()) + } + + secondBody := `{"model":"gemini-route","messages":[{"role":"user","content":"list files"},{"role":"assistant","content":null,"tool_calls":[{"id":"` + encodedID + `","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"}}]},{"role":"tool","tool_call_id":"` + encodedID + `","content":"[]"}]}` + second := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(secondBody)) + secondResponse := httptest.NewRecorder() + srv.handleChatCompletions(secondResponse, second) + if secondResponse.Code != http.StatusOK || !strings.Contains(secondResponse.Body.String(), `"content":"done"`) { + t.Fatalf("second status=%d body=%s", secondResponse.Code, secondResponse.Body.String()) + } + if len(providerRequests) != 2 { + t.Fatalf("provider requests=%d", len(providerRequests)) + } + messages := anySlice(providerRequests[1]["messages"]) + replayedCall := anySlice(messages[1].(map[string]any)["tool_calls"])[0].(map[string]any) + if replayedCall["id"] != "call-1" || messages[2].(map[string]any)["tool_call_id"] != "call-1" { + t.Fatalf("provider did not receive original tool ids: %+v", messages) + } + google := replayedCall["extra_content"].(map[string]any)["google"].(map[string]any) + if google["thought_signature"] != "opaque-signature" { + t.Fatalf("provider did not receive restored signature: %+v", replayedCall) + } +} diff --git a/apps/edge/internal/openai/provider_test_support_test.go b/apps/edge/internal/openai/provider_test_support_test.go index b6c7e407..8743c50a 100644 --- a/apps/edge/internal/openai/provider_test_support_test.go +++ b/apps/edge/internal/openai/provider_test_support_test.go @@ -295,6 +295,9 @@ func (s *providerFakeRunService) SubmitProviderPool(_ context.Context, req edges disp.ProfileID = selected.ProfileID disp.ProfileDriver = selected.ProfileDriver disp.ProfileOperation = req.Tunnel.Operation + if mapping, ok := selected.ProtocolProfile.ToolCallMapping(config.ProtocolOperation(req.Tunnel.Operation)); ok { + disp.ProfileToolCallWire = mapping.Wire + } disp.ProfileCapabilities = append([]string(nil), selected.ProfileCapabilities...) if selected.ProviderID != "" { disp.ProviderID = selected.ProviderID @@ -361,15 +364,19 @@ func (s *providerFakeRunService) SubmitProviderPool(_ context.Context, req edges handle := &fakeTunnelHandle{ dispatch: edgeservice.RunDispatch{ - RunID: "run-tunnel", - NodeID: "node-1", - ProviderID: disp.ProviderID, - UsageAttribution: disp.UsageAttribution, - ModelGroupKey: req.Tunnel.ModelGroupKey, - Adapter: req.Tunnel.Adapter, - Target: disp.Target, - SessionID: req.Tunnel.SessionID, - TimeoutSec: 5, + RunID: "run-tunnel", + NodeID: "node-1", + ProviderID: disp.ProviderID, + UsageAttribution: disp.UsageAttribution, + ModelGroupKey: req.Tunnel.ModelGroupKey, + Adapter: req.Tunnel.Adapter, + Target: disp.Target, + SessionID: req.Tunnel.SessionID, + TimeoutSec: 5, + ProfileID: disp.ProfileID, + ProfileDriver: disp.ProfileDriver, + ProfileOperation: disp.ProfileOperation, + ProfileToolCallWire: disp.ProfileToolCallWire, }, headers: req.Tunnel.Headers, frames: frames, diff --git a/apps/edge/internal/openai/provider_tunnel.go b/apps/edge/internal/openai/provider_tunnel.go index 82f6f488..4e88feb4 100644 --- a/apps/edge/internal/openai/provider_tunnel.go +++ b/apps/edge/internal/openai/provider_tunnel.go @@ -306,7 +306,7 @@ func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Requ assembler := &providerChatAssembler{streaming: reqStream} usageBinding := newUsageDispatchBinding(handle.Dispatch(), responseModePassthrough) - modelRewriter := newProviderModelRewriter(reqStream, requestModel) + modelRewriter := newProviderModelRewriterForDispatch(reqStream, requestModel, handle.Dispatch()) wroteHeader := false bodyBytes := 0 diff --git a/apps/edge/internal/openai/stream_gate_release_sink.go b/apps/edge/internal/openai/stream_gate_release_sink.go index 9efb97ba..a09b79ab 100644 --- a/apps/edge/internal/openai/stream_gate_release_sink.go +++ b/apps/edge/internal/openai/stream_gate_release_sink.go @@ -8,6 +8,7 @@ import ( "time" "go.uber.org/zap" + edgeservice "iop/apps/edge/internal/service" "iop/packages/go/streamgate" ) @@ -283,6 +284,19 @@ func openAITunnelCodecStateForSink(sink streamgate.ReleaseSink) *openAITunnelCod } } +func bindOpenAITunnelToolCallWire(sink streamgate.ReleaseSink, dispatch edgeservice.RunDispatch) { + switch typed := sink.(type) { + case *openAITunnelReleaseSink: + typed.mu.Lock() + if typed.rewriter != nil { + typed.rewriter.setToolCallWire(dispatch.ProfileToolCallWire) + } + typed.mu.Unlock() + case *openAICompositeReleaseSink: + bindOpenAITunnelToolCallWire(typed.tunnel, dispatch) + } +} + // newOpenAIBufferedTunnelReleaseSink builds the non-streaming passthrough sink. // requestModel is the caller-facing alias used to rewrite the provider model // echo; an empty value keeps provider-original bytes. diff --git a/apps/edge/internal/openai/stream_gate_runtime.go b/apps/edge/internal/openai/stream_gate_runtime.go index 5a71f37d..637dc231 100644 --- a/apps/edge/internal/openai/stream_gate_runtime.go +++ b/apps/edge/internal/openai/stream_gate_runtime.go @@ -1283,14 +1283,15 @@ func (s *Server) newOpenAIChatAttemptEventSourceFactory( // A fresh rewriter/assembler per attempt so an aborted attempt's // partial rewrite or usage state never bleeds into its replacement. assembler := &providerChatAssembler{streaming: dc.req.Stream} - rewriter := newProviderModelRewriter(dc.req.Stream, dc.req.Model) + dispatch := transport.tunnel.Dispatch() + bindOpenAITunnelToolCallWire(cfg.sink, dispatch) + rewriter := newProviderModelRewriterForDispatch(dc.req.Stream, dc.req.Model, dispatch) state := openAITunnelCodecStateForSink(cfg.sink) state.reset() tunnelSrc := newOpenAITunnelEndpointEventSource( transport.tunnel.Stream(), transport.tunnel.WaitTimeout(), rewriter, assembler, openAIRebuildEndpointChat, state, ) - dispatch := transport.tunnel.Dispatch() tunnelSrc.onTerminal = func(obs *providerAssembledObservation, bodyBytes int) { s.logger.Info("openai chat completion passthrough closed", zap.String("run_id", dispatch.RunID), @@ -1877,7 +1878,9 @@ func (s *Server) buildOpenAITunnelStreamGateRuntime( return nil, fmt.Errorf("openai stream gate: unsupported recovery transport path %q for provider tunnel", transport.path) } assembler := &providerChatAssembler{streaming: req.stream} - rewriter := newProviderModelRewriter(req.stream, req.requestModel) + dispatch := transport.tunnel.Dispatch() + bindOpenAITunnelToolCallWire(sink, dispatch) + rewriter := newProviderModelRewriterForDispatch(req.stream, req.requestModel, dispatch) state := openAITunnelCodecStateForSink(sink) if responseSink != nil { state = openAIResponsesTunnelCodecStateForSink(responseSink) @@ -1910,7 +1913,8 @@ func (s *Server) buildOpenAITunnelStreamGateRuntime( initialTransport := openAIAttemptTransport{path: openAIAdmissionTunnel, tunnel: handle} initialTransport.bindUsage(dispatch) initialAssembler := &providerChatAssembler{streaming: req.stream} - initialRewriter := newProviderModelRewriter(req.stream, req.requestModel) + bindOpenAITunnelToolCallWire(sink, dispatch) + initialRewriter := newProviderModelRewriterForDispatch(req.stream, req.requestModel, dispatch) initialState := openAITunnelCodecStateForSink(sink) if responseSink != nil { initialState = openAIResponsesTunnelCodecStateForSink(responseSink) diff --git a/apps/edge/internal/service/protocol_profile_test.go b/apps/edge/internal/service/protocol_profile_test.go index 345bf12a..e48e1b80 100644 --- a/apps/edge/internal/service/protocol_profile_test.go +++ b/apps/edge/internal/service/protocol_profile_test.go @@ -98,6 +98,17 @@ func TestProtocolProfileCandidateCopy(t *testing.T) { } } +func TestProtocolProfileToolCallWireFact(t *testing.T) { + gemini := mustResolveProfileForService(t, "gemini") + if got := profileToolCallWire(&gemini, string(config.OperationChatCompletions)); got != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("Gemini tool-call wire = %q", got) + } + openAI := mustResolveProfileForService(t, "openai") + if got := profileToolCallWire(&openAI, string(config.OperationChatCompletions)); got != "" { + t.Fatalf("OpenAI tool-call wire = %q, want empty", got) + } +} + func TestProtocolProfileAdapterDispatchUsesProviderID(t *testing.T) { profile := mustResolveProfileForService(t, "openai") provider := config.NodeProviderConf{ diff --git a/apps/edge/internal/service/provider_pool.go b/apps/edge/internal/service/provider_pool.go index 361b9ced..4885fbab 100644 --- a/apps/edge/internal/service/provider_pool.go +++ b/apps/edge/internal/service/provider_pool.go @@ -306,6 +306,17 @@ func profileFacts(p *config.ConcreteProtocolProfile) (id, driver string) { return p.ID, string(p.Driver) } +func profileToolCallWire(p *config.ConcreteProtocolProfile, operation string) string { + if p == nil { + return "" + } + mapping, ok := p.ToolCallMapping(config.ProtocolOperation(operation)) + if !ok { + return "" + } + return mapping.Wire +} + // dispatchProviderPoolTunnel relays the selected candidate's raw provider // request after provider-pool admission. The tunnel inherits the Run's // identity, metadata, and long-context classification so passthrough dispatch @@ -393,6 +404,7 @@ func (s *Service) dispatchProviderPoolTunnel( disp.QueueReason = queueReason disp.ProfileID, disp.ProfileDriver = profileFacts(selected.profile) disp.ProfileOperation = tunnelReq.Operation + disp.ProfileToolCallWire = profileToolCallWire(selected.profile, tunnelReq.Operation) if selected.profile != nil { disp.ProfileCapabilities = append([]string(nil), selected.profile.Capabilities...) } diff --git a/apps/edge/internal/service/run_types.go b/apps/edge/internal/service/run_types.go index fab71e00..7c974b7f 100644 --- a/apps/edge/internal/service/run_types.go +++ b/apps/edge/internal/service/run_types.go @@ -64,6 +64,7 @@ type RunDispatch struct { ProfileID string ProfileDriver string ProfileOperation string + ProfileToolCallWire string ProfileCapabilities []string CredentialSlotRef string CredentialRevision uint64 diff --git a/packages/go/config/protocol_profile.go b/packages/go/config/protocol_profile.go index e038bcbd..586a94dc 100644 --- a/packages/go/config/protocol_profile.go +++ b/packages/go/config/protocol_profile.go @@ -111,6 +111,14 @@ type ProtocolEffortMappingConf struct { Wire string `mapstructure:"wire" yaml:"wire,omitempty"` } +// ProtocolToolCallMappingConf declares provider-specific tool-call metadata +// normalization for one operation. It is separate from effort mapping because +// reasoning grades and opaque tool continuation metadata are independent wire +// concerns even when one provider uses both. +type ProtocolToolCallMappingConf struct { + Wire string `mapstructure:"wire" yaml:"wire,omitempty"` +} + const ( ProtocolEffortWireOpenAIChat = "openai_chat" ProtocolEffortWireOpenAIResponses = "openai_responses" @@ -125,11 +133,18 @@ var validProtocolEffortWires = map[string]struct{}{ ProtocolEffortWireGeminiChat: {}, } +const ProtocolToolCallWireGeminiChat = "gemini_openai_chat" + +var validProtocolToolCallWires = map[string]struct{}{ + ProtocolToolCallWireGeminiChat: {}, +} + // ProtocolNormalizationConf contains provider-wire normalization facts. It is // intentionally operation-scoped: a provider may support reasoning with tools // on Responses while rejecting the same semantic request on Chat Completions. type ProtocolNormalizationConf struct { - Effort map[string]ProtocolEffortMappingConf `mapstructure:"effort" yaml:"effort,omitempty"` + Effort map[string]ProtocolEffortMappingConf `mapstructure:"effort" yaml:"effort,omitempty"` + ToolCalls map[string]ProtocolToolCallMappingConf `mapstructure:"tool_calls" yaml:"tool_calls,omitempty"` } // ProtocolProfileConf is the overlayable configuration of a protocol profile. @@ -235,6 +250,8 @@ var builtInProtocolProfiles = map[string]ProtocolProfileConf{ Capabilities: []string{"models", "chat", "streaming", "tool_calling"}, Normalization: ProtocolNormalizationConf{Effort: map[string]ProtocolEffortMappingConf{ string(OperationChatCompletions): identityEffortMapping(ProtocolEffortWireGeminiChat, true, true), + }, ToolCalls: map[string]ProtocolToolCallMappingConf{ + string(OperationChatCompletions): {Wire: ProtocolToolCallWireGeminiChat}, }}, }, "anthropic": { @@ -459,6 +476,12 @@ func deepCopyProfileConf(src ProtocolProfileConf) ProtocolProfileConf { dst.Normalization.Effort[operation] = cloneEffortMapping(mapping) } } + if src.Normalization.ToolCalls != nil { + dst.Normalization.ToolCalls = make(map[string]ProtocolToolCallMappingConf, len(src.Normalization.ToolCalls)) + for operation, mapping := range src.Normalization.ToolCalls { + dst.Normalization.ToolCalls[operation] = mapping + } + } return dst } @@ -629,6 +652,14 @@ func mergeProfileOverlay(base, overlay ProtocolProfileConf) (ProtocolProfileConf merged.Normalization.Effort[operation] = cloneEffortMapping(mapping) } } + if len(overlay.Normalization.ToolCalls) > 0 { + if merged.Normalization.ToolCalls == nil { + merged.Normalization.ToolCalls = make(map[string]ProtocolToolCallMappingConf) + } + for operation, mapping := range overlay.Normalization.ToolCalls { + merged.Normalization.ToolCalls[operation] = mapping + } + } return merged, nil } @@ -705,6 +736,14 @@ func validateConcreteProfile(id string, p ProtocolProfileConf) error { } } } + for operation, mapping := range p.Normalization.ToolCalls { + if _, ok := p.Operations[operation]; !ok { + return fmt.Errorf("profile %q: tool-call normalization operation %q is not declared", id, operation) + } + if _, ok := validProtocolToolCallWires[mapping.Wire]; !ok { + return fmt.Errorf("profile %q: tool-call normalization operation %q has invalid wire %q", id, operation, mapping.Wire) + } + } if p.Auth.Header == "" { return fmt.Errorf("profile %q: auth.header must not be empty", id) } @@ -857,6 +896,13 @@ func (p ConcreteProtocolProfile) EffortMapping(operation ProtocolOperation) (Pro return cloneEffortMapping(mapping), true } +// ToolCallMapping returns the operation-scoped provider tool-call +// normalization facts. The returned value contains no caller identity. +func (p ConcreteProtocolProfile) ToolCallMapping(operation ProtocolOperation) (ProtocolToolCallMappingConf, bool) { + mapping, ok := p.Normalization.ToolCalls[string(operation)] + return mapping, ok +} + // Clone returns a deep copy of the concrete profile. func (p ConcreteProtocolProfile) Clone() ConcreteProtocolProfile { return ConcreteProtocolProfile{ diff --git a/packages/go/config/protocol_profile_test.go b/packages/go/config/protocol_profile_test.go index b75168b4..50c3d1a3 100644 --- a/packages/go/config/protocol_profile_test.go +++ b/packages/go/config/protocol_profile_test.go @@ -152,6 +152,45 @@ func TestProtocolProfileEffortNormalizationOverlayAndValidation(t *testing.T) { } } +func TestProtocolProfileToolCallNormalization(t *testing.T) { + gemini, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatalf("ResolveProtocolProfile(gemini): %v", err) + } + mapping, ok := gemini.ToolCallMapping(config.OperationChatCompletions) + if !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("Gemini Chat tool-call mapping = %+v,%t", mapping, ok) + } + openAI, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatalf("ResolveProtocolProfile(openai): %v", err) + } + if _, ok := openAI.ToolCallMapping(config.OperationChatCompletions); ok { + t.Fatal("OpenAI profile must not inherit Gemini tool-call normalization") + } + + catalog := config.BuiltInProtocolProfileCatalog() + catalog["custom-gemini"] = config.ProtocolProfileConf{Base: "gemini", BaseURL: "https://proxy.example.invalid/v1"} + custom, err := config.ResolveProtocolProfile("custom-gemini", "", catalog) + if err != nil { + t.Fatalf("ResolveProtocolProfile(custom-gemini): %v", err) + } + if mapping, ok := custom.ToolCallMapping(config.OperationChatCompletions); !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("custom Gemini overlay lost tool-call mapping: %+v,%t", mapping, ok) + } + + bad := config.BuiltInProtocolProfileCatalog() + bad["bad-tool-wire"] = config.ProtocolProfileConf{ + Base: "openai", + Normalization: config.ProtocolNormalizationConf{ToolCalls: map[string]config.ProtocolToolCallMappingConf{ + string(config.OperationChatCompletions): {Wire: "unknown"}, + }}, + } + if _, err := config.ResolveProtocolProfile("bad-tool-wire", "", bad); err == nil || !strings.Contains(err.Error(), "invalid wire") { + t.Fatalf("expected invalid tool-call wire error, got %v", err) + } +} + func TestProtocolProfileOverlayRejected(t *testing.T) { t.Run("cycle", func(t *testing.T) { catalog := map[string]config.ProtocolProfileConf{