fix(openai): 프로필별 Chat 호출을 정규화한다
OpenCode의 일반 Chat 요청을 GPT provider가 거부한 뒤 재시도 가능한 오류로 왜곡해 벤치가 장시간 정체됐다. 선택된 protocol profile에 맞춰 출력 토큰 필드를 정규화하고 upstream 400을 비재시도 validation 오류로 유지한다.
This commit is contained in:
parent
6345d52105
commit
0110ca1af8
14 changed files with 355 additions and 30 deletions
|
|
@ -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, 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.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, selected-profile Chat token-limit alias normalization, unmarked caller-workspace upstream 400 projection, 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, unmarked caller-workspace light continuation, 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/artifact_pair.go`, `apps/edge/internal/openai/hot_path_light.go`, `apps/edge/internal/openai/hot_path_review.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` |
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
이 문서는 외부 프로젝트가 IOP Edge의 OpenAI-compatible HTTP 표면을 호출할 때 확인할 계약 원문이다.
|
||||
IOP 내부 실행은 `adapter + target` 기준이며, OpenAI-compatible 경계에서는 호환성을 위해 `model`을 사용한다.
|
||||
IOP 고유 실행 문맥은 별도 `iop` wrapper field를 만들지 않고 OpenAI request의 `metadata`에 둔다.
|
||||
기본 설계 기준은 OpenAI-compatible request/response surface 보존이다. OpenAI-compatible provider로 raw passthrough 되는 경로는 선택된 provider가 지원하는 표준 field와 provider extension field를 IOP allowlist로 제한하지 않는다. IOP 고유 field나 추상화 field는 OpenAI-compatible 기본 surface 위에 더하는 확장으로만 사용하며, provider-native OpenAI-compatible field를 대체하거나 금지하지 않는다.
|
||||
기본 설계 기준은 OpenAI-compatible request/response surface 보존이다. OpenAI-compatible provider로 raw passthrough 되는 경로는 선택된 provider가 지원하는 표준 field와 provider extension field를 IOP allowlist로 제한하지 않는다. 단, Chat 출력 상한의 호환 alias인 `max_tokens`와 `max_completion_tokens`는 selected protocol profile이 선언한 wire spelling으로 정규화하며, 둘 다 있으면 target-native field 값을 우선한다. IOP 고유 field나 추상화 field는 OpenAI-compatible 기본 surface 위에 더하는 확장으로만 사용하며, 그 밖의 provider-native OpenAI-compatible field를 대체하거나 금지하지 않는다.
|
||||
라우팅의 1차 기준은 request `model`이 가리키는 route/provider capability다. 선택된 provider가 OpenAI-compatible provider이면 Edge는 provider tunnel passthrough를 사용하고, 그 외 Ollama/native 실행은 normalized path를 사용한다. 라우팅과 응답 형태를 caller metadata selector로 고르지 않는다. 2차 처리는 OpenAI `metadata` container에서 IOP가 아는 bounded key만 발췌해 principal, usage/observability 같은 내부 문맥으로 쓰는 방식이다.
|
||||
서로 다른 외부 `model` key가 같은 `nodes[].providers[].id`를 참조하면 일반·long-context capacity는 model group별이 아니라 해당 provider resource 하나에서 공유된다. Edge provider-pool queue의 전체 pending 상한과 timeout도 model group 공통 root policy를 사용한다.
|
||||
|
||||
|
|
@ -112,6 +112,7 @@ After provider-pool admission, Edge validates the exact route/slot/profile/model
|
|||
- non-stream 오류는 해당 HTTP status와 JSON envelope 하나로 반환한다.
|
||||
- normalized Chat Completions stream의 런타임 오류는 같은 `type/message` envelope를 SSE `data`로 한 번 쓰고 `[DONE]`으로 종료한다.
|
||||
- normalized `/v1/responses`는 현재 streaming을 지원하지 않는다. provider-pool raw passthrough stream은 선택된 provider의 status/header/body를 그대로 relay하며 IOP envelope로 감싸지 않는다.
|
||||
- unmarked caller-workspace selector의 upstream Chat 응답이 HTTP `400`이면 동일 요청 안에서 재시도하지 않고 caller에게 HTTP `400`, `error.type="invalid_request_error"`로 한 번 투영한다. Provider body와 endpoint는 private으로 유지한다. Upstream `5xx`와 transport failure는 기존 sanitized `502` 경계를 유지한다.
|
||||
|
||||
### Stream Evidence Gate ingress 및 terminal 오류
|
||||
|
||||
|
|
@ -259,13 +260,14 @@ Normalized(non-provider) Chat Completions route가 해석하는 request field:
|
|||
- `thinking_token_budget`
|
||||
- `include_reasoning`
|
||||
|
||||
Provider-pool raw passthrough route는 위 목록을 provider request allowlist로 사용하지 않는다. 이 경로의 기본은 selected OpenAI-compatible provider가 지원하는 요청 surface 보존이며, `chat_template_kwargs`, provider별 `extra_body`/template option, 새 OpenAI-compatible field처럼 IOP가 아직 해석하지 않는 top-level field도 model rewrite 후 provider로 전달되어야 한다. 해당 field의 성공/실패 의미는 provider가 결정하고, IOP는 provider HTTP status/header/body를 relay한다.
|
||||
Provider-pool raw passthrough route는 위 목록을 provider request allowlist로 사용하지 않는다. 이 경로의 기본은 selected OpenAI-compatible provider가 지원하는 요청 surface 보존이며, `chat_template_kwargs`, provider별 `extra_body`/template option, 새 OpenAI-compatible field처럼 IOP가 아직 해석하지 않는 top-level field도 model rewrite 후 provider로 전달되어야 한다. 단, Chat 출력 상한 alias는 selected profile에 맞춰 `max_tokens` 또는 `max_completion_tokens` 하나로 정규화하고 둘 다 있으면 target-native 값을 보존한다. 해당 field의 성공/실패 의미는 provider가 결정하고, IOP는 provider HTTP status/header/body를 relay한다.
|
||||
|
||||
### Chat Completions routing and response
|
||||
|
||||
Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다.
|
||||
|
||||
- provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다.
|
||||
- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review Chat 요청은 모두 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화하며 caller/agent identity로 분기하지 않는다.
|
||||
- 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는 보존한다.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ AI agent가 작업 전에 읽는 지도이기도 하지만, 사람도 "지금
|
|||
| `runtime/edge-node-execution` | 구현됨 | Edge-Node mTLS/protobuf transport, Node 등록, transport heartbeat/reconnect, provider run/cancel/command, provider raw tunnel, signed/sealed credential lease consumption, single-request Plan/Review effective template 적용을 확인할 때 | `agent-spec/runtime/edge-node-execution.md` | `agent-contract/inner/execution-runtime.md`, `agent-contract/inner/edge-node-runtime-wire.md`, `apps/edge/internal/transport/server.go`, `apps/node/internal/transport/client.go`, `packages/go/singlerequesttemplate/template.go` |
|
||||
| `runtime/stream-evidence-gate` | 구현됨 | Stream Evidence Gate의 normalized event, evidence hold/release, filter registry, recovery coordinator, OpenAI request rebuild와 observation을 확인할 때 | `agent-spec/runtime/stream-evidence-gate.md` | `packages/go/streamgate/runtime.go`, `apps/edge/internal/openai/stream_gate_runtime.go`, `agent-contract/outer/openai-compatible-api.md` |
|
||||
| `runtime/provider-pool-config-refresh` | 부분 | `credential_plane`, managed/legacy exclusivity, TLS/key references, `models[]`, top-level `protocol_profiles`, `nodes[].providers[].profile`, provider-pool dispatch, long-context admission, `execution_presets[].single_request.templates`의 relative-only 로딩과 admission freeze, and restart/applied refresh classification을 확인할 때 | `agent-spec/runtime/provider-pool-config-refresh.md` | `agent-contract/inner/edge-config-runtime-refresh.md`, `packages/go/config/provider_types.go`, `packages/go/config/validate.go`, `packages/go/config/load.go`, `apps/edge/internal/configrefresh/classify.go` |
|
||||
| `input/openai-compatible-surface` | 부분 | `/v1/models`, `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/anthropic/v1/models`, managed projection/slot routing, OpenAI-compatible auth/metadata/tool handling, Anthropic bearer/`X-Api-Key` auth, provider-pool native/bridge admission, safe slot attribution, marked single-request 내부 stage template과 caller-visible I/O 경계, and OpenAI-only usage metrics를 확인할 때 | `agent-spec/input/openai-compatible-surface.md` | `agent-contract/outer/openai-compatible-api.md`, `agent-contract/outer/anthropic-compatible-api.md`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/anthropic_handler.go`, `apps/edge/internal/openai/anthropic_bridge.go`, `apps/edge/internal/openai/normalized_sse.go`, `apps/edge/internal/openai/usage_metrics.go` |
|
||||
| `input/openai-compatible-surface` | 부분 | `/v1/models`, `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/anthropic/v1/models`, managed projection/slot routing, OpenAI-compatible auth/metadata/tool handling, selected-profile Chat token-limit normalization, unmarked caller-workspace upstream 400 projection, Anthropic bearer/`X-Api-Key` auth, provider-pool native/bridge admission, safe slot attribution, marked single-request 내부 stage template과 caller-visible I/O 경계, and OpenAI-only usage metrics를 확인할 때 | `agent-spec/input/openai-compatible-surface.md` | `agent-contract/outer/openai-compatible-api.md`, `agent-contract/outer/anthropic-compatible-api.md`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/anthropic_handler.go`, `apps/edge/internal/openai/anthropic_bridge.go`, `apps/edge/internal/openai/normalized_sse.go`, `apps/edge/internal/openai/usage_metrics.go` |
|
||||
| `input/a2a-json-rpc-surface` | 부분 | Edge A2A JSON-RPC, `message/send`, `tasks/get`, `tasks/cancel`, A2A task store와 bearer auth를 확인할 때 | `agent-spec/input/a2a-json-rpc-surface.md` | `agent-contract/outer/a2a-json-rpc-api.md`, `apps/edge/internal/input/a2a/server.go`, `apps/edge/internal/input/a2a/task_store.go` |
|
||||
| `control/control-plane-operations` | 부분 | credential HTTPS and host-local bootstrap, Control Plane-Edge mTLS projection/lease wire, Client-Control Plane wire, Control Plane HTTP Edge/fleet status view, Flutter Client status consumer를 확인할 때 | `agent-spec/control/control-plane-operations.md` | `agent-contract/inner/control-plane-edge-wire.md`, `agent-contract/inner/client-control-plane-wire.md`, `apps/control-plane/internal/wire/edge_server.go`, `apps/control-plane/internal/credentiallease/service.go` |
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ source_evidence:
|
|||
notes: unmarked light preset의 caller-workspace PLAN→Work REVIEW handoff→Review inspection/repair 상태
|
||||
- type: code
|
||||
path: apps/edge/internal/openai/provider_model_rewrite.go
|
||||
notes: unmarked selector의 frontier에 따라 prepare-only 또는 pair-write relative-path grammar를 실제 Chat/Messages provider body에 주입하는 경계
|
||||
notes: selected-profile Chat token-limit alias normalization과 unmarked selector frontier에 따른 prepare-only/pair-write instruction 주입 경계
|
||||
- type: test
|
||||
path: apps/edge/internal/openai/hot_path_light_test.go
|
||||
notes: OpenAI/Anthropic caller-workspace handoff, reviewer inspection, repair, terminal, cleanup 회귀 검증
|
||||
|
|
@ -208,7 +208,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. |
|
||||
| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/<request_id>` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/<request_id>` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. |
|
||||
| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/<request_id>` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/<request_id>` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. |
|
||||
| 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/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, 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 first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. 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. |
|
||||
|
|
@ -238,8 +238,8 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
|
|||
| repeat-resume request shape | A selected continuation uses only request-local assistant content/reasoning plus a fixed English directive. Chat emits assistant provenance followed by the directive; Responses emits assistant output/reasoning items and places the directive in `instructions`. Caller messages, `input`, and original `instructions` are excluded. |
|
||||
| repeat history boundary | Chat and Responses use separate endpoint decoders to create a bounded raw-free role/channel/action snapshot from the current request only. User occurrences exclude assistant anchors; missing reasoning does not infer lineage or TTL state. |
|
||||
| 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를 보존한다. |
|
||||
| provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP 확장 envelope를 섞지 않는다. Chat request의 출력 상한 alias만 selected profile wire로 bounded normalization한다. |
|
||||
| provider-native field 보존 | provider raw tunnel route는 `model` served target rewrite, auth/header 처리, selected-profile Chat token-limit alias normalization 외에 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. |
|
||||
|
|
@ -311,6 +311,7 @@ sequenceDiagram
|
|||
- Claude Code Messages requests may use adaptive thinking, `output_config.effort`, structured output, cache-control annotations, and supported beta headers, including the compatibility-only `advisor-tool-2026-03-01` marker emitted by the pinned official caller. The Chat bridge consumes rather than forwards those headers, maps supported fields, and requires callers to replay opaque `tool_use.id` values unchanged so Gemini thought signatures can be restored on tool-result turns.
|
||||
- provider capacity와 long-context slot은 model alias별이 아니라 `node_id + provider_id`별로 공유한다. queue pending 상한과 timeout은 Edge root `provider_pool` policy이며, lease 반환·refresh·disconnect/reconnect가 모든 model group waiter를 global enqueue 순서로 재평가한다.
|
||||
- provider가 full이면 queue policy에 따라 대기하지만 live candidate가 모두 사라지면 즉시 unavailable로 수렴한다. Chat Completions와 Responses provider-pool 표면은 새 public status/field 없이 HTTP 502 `node_dispatch_error`를 유지한다.
|
||||
- Unmarked caller-workspace selector의 upstream HTTP 400은 validation으로 분류되어 재시도 없이 caller HTTP 400 `invalid_request_error` 한 번으로 끝난다. Upstream body/endpoint는 노출하지 않으며 upstream 5xx와 transport failure는 sanitized 502를 유지한다.
|
||||
- In legacy mode, `openai.provider_auth` stores only a forwarding rule and reads raw provider material from its request-time header; inbound IOP authorization is never reused. Managed mode rejects that rule and the caller header and uses only the sealed slot lease.
|
||||
- OpenAI request metadata is bounded caller context. Workspace, runtime, and session ownership are outside this input surface.
|
||||
- Chat Completions와 Responses request는 caller metadata로 provider raw tunnel과 normalized response shape를 선택하지 않는다. route/provider capability만 실행 경로를 결정한다.
|
||||
|
|
@ -370,6 +371,7 @@ sequenceDiagram
|
|||
|
||||
## 변경 기록
|
||||
|
||||
- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command.
|
||||
- 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately.
|
||||
- 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas.
|
||||
- 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets.
|
||||
|
|
|
|||
|
|
@ -393,6 +393,10 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch
|
|||
if err != nil {
|
||||
return tunnelReq, err
|
||||
}
|
||||
prepared, err = prepareProviderChatRequestNormalization(prepared, selected)
|
||||
if err != nil {
|
||||
return tunnelReq, err
|
||||
}
|
||||
prepared, err = prepareProviderChatToolCallNormalization(prepared, selected)
|
||||
if err != nil {
|
||||
return tunnelReq, err
|
||||
|
|
@ -460,8 +464,12 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch
|
|||
Kind: hotPathDispositionForError(turnErr), Cause: turnErr.Error(), Source: "selector_collection",
|
||||
}
|
||||
}
|
||||
errorType := "run_error"
|
||||
if disposition.Kind == hotPathDispositionValidationError {
|
||||
errorType = "invalid_request_error"
|
||||
}
|
||||
_ = presetChatCodec.writeDisposition(
|
||||
w, disposition, httpStatusForRunError(turnErr), "run_error", turnErr.Error(),
|
||||
w, disposition, httpStatusForRunError(turnErr), errorType, turnErr.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,6 +241,25 @@ func TestHotPathChatProviderErrorBeforeCommit(t *testing.T) {
|
|||
assertHotPathTerminal(t, srv)
|
||||
}
|
||||
|
||||
func TestHotPathChatProviderBadRequestStaysNonRetryable(t *testing.T) {
|
||||
candidate := anthropicTestCandidate(t, "openai")
|
||||
frames := make(chan *iop.ProviderTunnelFrame, 2)
|
||||
frames <- &iop.ProviderTunnelFrame{
|
||||
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
||||
close(frames)
|
||||
srv, fake := newHotPathHandlerServer(t, candidate, frames)
|
||||
response := serveHotPathChatBody(t, srv, `{"model":"virtual-model","messages":[{"role":"user","content":"invalid"}],"stream":true}`)
|
||||
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"type":"invalid_request_error"`) || strings.Contains(response.Body.String(), "[DONE]") {
|
||||
t.Fatalf("provider rejection projection mismatch: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if fake.poolSubmitCountSnapshot() != 1 {
|
||||
t.Fatalf("selector submissions=%d, want 1", fake.poolSubmitCountSnapshot())
|
||||
}
|
||||
assertHotPathTerminal(t, srv)
|
||||
}
|
||||
|
||||
func TestHotPathChatFlushesVisibleDeltaBeforeProviderTerminal(t *testing.T) {
|
||||
candidate := anthropicTestCandidate(t, "openai")
|
||||
frames := make(chan *iop.ProviderTunnelFrame, 4)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -450,7 +451,7 @@ func collectPresetTunnelResult(ctx context.Context, handle edgeservice.ProviderT
|
|||
return normalizedStageOutput{}, fmt.Errorf("%s", message)
|
||||
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END:
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
return normalizedStageOutput{}, fmt.Errorf("preset selector provider returned HTTP %d", status)
|
||||
return normalizedStageOutput{}, &providerHTTPStatusError{status: status, stage: "selector"}
|
||||
}
|
||||
stage, err := decodePresetTunnelBody(body.Bytes(), contentType, protocol, selected.ProfileOperation, selected.ProfileDriver)
|
||||
if err != nil {
|
||||
|
|
@ -482,6 +483,28 @@ func collectPresetTunnelResult(ctx context.Context, handle edgeservice.ProviderT
|
|||
}
|
||||
}
|
||||
|
||||
// providerHTTPStatusError retains only the upstream status needed for a safe
|
||||
// caller disposition. Provider response bodies and headers remain private.
|
||||
type providerHTTPStatusError struct {
|
||||
status int
|
||||
stage string
|
||||
}
|
||||
|
||||
func (e *providerHTTPStatusError) Error() string {
|
||||
if e == nil {
|
||||
return "preset provider request failed"
|
||||
}
|
||||
return fmt.Sprintf("preset %s provider returned HTTP %d", e.stage, e.status)
|
||||
}
|
||||
|
||||
func providerHTTPStatus(err error) (int, bool) {
|
||||
var statusErr *providerHTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr == nil {
|
||||
return 0, false
|
||||
}
|
||||
return statusErr.status, true
|
||||
}
|
||||
|
||||
func validateProviderStageMetadata(protocol string, stage normalizedStageOutput) error {
|
||||
if strings.TrimSpace(stage.ResponseID) == "" {
|
||||
return fmt.Errorf("provider response is missing required identity")
|
||||
|
|
@ -1533,7 +1556,7 @@ func (s *Server) prepareHotPathStageTunnel(r *http.Request, snapshot hotPathDisp
|
|||
prepared.BuildBody = func(target string) ([]byte, error) {
|
||||
return hotPathChatStageBody(snapshot, prompt, target)
|
||||
}
|
||||
return prepared, nil
|
||||
return prepareProviderChatRequestNormalization(prepared, selected)
|
||||
case config.ProtocolDriverAnthropicMessages:
|
||||
request := r.Clone(r.Context())
|
||||
if strings.TrimSpace(request.Header.Get(anthropicVersionHeader)) == "" {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -66,6 +67,9 @@ func hotPathDispositionForSuccess(reason string, hasTools bool) hotPathDispositi
|
|||
}
|
||||
|
||||
func hotPathDispositionForError(err error) hotPathDispositionKind {
|
||||
if status, ok := providerHTTPStatus(err); ok && status == http.StatusBadRequest {
|
||||
return hotPathDispositionValidationError
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return hotPathDispositionCallerCancel
|
||||
|
|
|
|||
|
|
@ -518,6 +518,92 @@ func prepareProviderChatToolCallNormalization(tunnel edgeservice.SubmitProviderT
|
|||
return tunnel, nil
|
||||
}
|
||||
|
||||
// prepareProviderChatRequestNormalization applies the bounded Chat field
|
||||
// aliases owned by the selected protocol profile. A generic OpenAI-compatible
|
||||
// caller can legally send either token-limit spelling, but current OpenAI Chat
|
||||
// profiles require max_completion_tokens while Gemini and legacy Chat wires
|
||||
// use max_tokens. Unknown fields and their original byte ranges remain
|
||||
// untouched.
|
||||
func prepareProviderChatRequestNormalization(tunnel edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
|
||||
profile := selected.ProtocolProfile
|
||||
if profile == nil || tunnel.Operation != string(config.OperationChatCompletions) {
|
||||
return tunnel, nil
|
||||
}
|
||||
mapping, ok := profile.EffortMapping(config.OperationChatCompletions)
|
||||
if !ok {
|
||||
return tunnel, nil
|
||||
}
|
||||
var targetField string
|
||||
switch mapping.Wire {
|
||||
case config.ProtocolEffortWireOpenAIChat:
|
||||
targetField = "max_completion_tokens"
|
||||
case config.ProtocolEffortWireGeminiChat:
|
||||
targetField = "max_tokens"
|
||||
default:
|
||||
return tunnel, nil
|
||||
}
|
||||
rewrite := func(body []byte) ([]byte, error) {
|
||||
return normalizeChatTokenLimitField(body, targetField)
|
||||
}
|
||||
if tunnel.BuildBody != nil {
|
||||
build := tunnel.BuildBody
|
||||
tunnel.BuildBody = func(target string) ([]byte, error) {
|
||||
body, err := build(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rewrite(body)
|
||||
}
|
||||
return tunnel, nil
|
||||
}
|
||||
if len(tunnel.Body) == 0 {
|
||||
return tunnel, nil
|
||||
}
|
||||
body, err := rewrite(tunnel.Body)
|
||||
if err != nil {
|
||||
return tunnel, err
|
||||
}
|
||||
tunnel.Body = body
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
func normalizeChatTokenLimitField(body []byte, targetField string) ([]byte, error) {
|
||||
var limits struct {
|
||||
MaxTokens json.RawMessage `json:"max_tokens"`
|
||||
MaxCompletionTokens json.RawMessage `json:"max_completion_tokens"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &limits); err != nil {
|
||||
return nil, fmt.Errorf("decode Chat token limit fields: %w", err)
|
||||
}
|
||||
var value json.RawMessage
|
||||
var remove string
|
||||
switch targetField {
|
||||
case "max_completion_tokens":
|
||||
value, remove = limits.MaxCompletionTokens, "max_tokens"
|
||||
if len(value) == 0 {
|
||||
value = limits.MaxTokens
|
||||
}
|
||||
case "max_tokens":
|
||||
value, remove = limits.MaxTokens, "max_completion_tokens"
|
||||
if len(value) == 0 {
|
||||
value = limits.MaxCompletionTokens
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Chat token limit target %q", targetField)
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{
|
||||
{name: targetField, value: append(json.RawMessage(nil), value...)},
|
||||
{name: remove},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plan.apply(), nil
|
||||
}
|
||||
|
||||
func splitLineEnding(line []byte) ([]byte, []byte) {
|
||||
if len(line) == 0 || line[len(line)-1] != '\n' {
|
||||
return line, nil
|
||||
|
|
|
|||
|
|
@ -76,6 +76,54 @@ func TestProviderThoughtSignatureNormalizationIsGeminiProfileOnly(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderChatTokenLimitNormalizationUsesSelectedProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profileID string
|
||||
body string
|
||||
wantField string
|
||||
wantAbsent string
|
||||
want float64
|
||||
}{
|
||||
{name: "generic max tokens to OpenAI completion field", profileID: "openai", body: `{"model":"served","max_tokens":32000,"future":{"keep":true}}`, wantField: "max_completion_tokens", wantAbsent: "max_tokens", want: 32000},
|
||||
{name: "OpenAI native field wins", profileID: "openai", body: `{"model":"served","max_tokens":8,"max_completion_tokens":16}`, wantField: "max_completion_tokens", wantAbsent: "max_tokens", want: 16},
|
||||
{name: "completion field to Gemini legacy field", profileID: "gemini", body: `{"model":"served","max_completion_tokens":2048}`, wantField: "max_tokens", wantAbsent: "max_completion_tokens", want: 2048},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
profile, err := config.ResolveProtocolProfile(tc.profileID, "", config.BuiltInProtocolProfileCatalog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnel := edgeservice.SubmitProviderTunnelRequest{
|
||||
Operation: string(config.OperationChatCompletions),
|
||||
BuildBody: func(string) ([]byte, error) { return []byte(tc.body), nil },
|
||||
}
|
||||
prepared, err := prepareProviderChatRequestNormalization(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := prepared.BuildBody("served")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[tc.wantField] != tc.want {
|
||||
t.Fatalf("%s=%v, want %v; body=%s", tc.wantField, got[tc.wantField], tc.want, body)
|
||||
}
|
||||
if _, ok := got[tc.wantAbsent]; ok {
|
||||
t.Fatalf("%s survived normalization: %s", tc.wantAbsent, body)
|
||||
}
|
||||
if strings.Contains(tc.body, `"future"`) && !strings.Contains(string(body), `"future":{"keep":true}`) {
|
||||
t.Fatalf("unknown provider field changed: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -92,6 +140,55 @@ func TestGeminiChatProviderRejectsMalformedOpaqueToolID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatProviderHTTPNormalizesGenericTokenLimit(t *testing.T) {
|
||||
var providerRequest map[string]any
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &providerRequest); err != nil {
|
||||
t.Errorf("decode provider request: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"chat-openai","model":"served-openai","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &providerFakeRunService{
|
||||
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
||||
tunnelProviderURL: provider.URL,
|
||||
tunnelServedTarget: "served-openai",
|
||||
poolSelectedCandidate: edgeservice.ProviderPoolCandidate{
|
||||
ActualModel: "served-openai", ProviderID: "openai-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: "openai-route", Providers: map[string]string{"openai-provider": "served-openai"}}})
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"openai-route","messages":[{"role":"user","content":"hello"}],"max_tokens":32000,"future":{"keep":true}}`))
|
||||
response := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"content":"done"`) {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if providerRequest["max_completion_tokens"] != float64(32000) {
|
||||
t.Fatalf("max_completion_tokens=%v, request=%+v", providerRequest["max_completion_tokens"], providerRequest)
|
||||
}
|
||||
if _, ok := providerRequest["max_tokens"]; ok {
|
||||
t.Fatalf("legacy max_tokens reached OpenAI provider: %+v", providerRequest)
|
||||
}
|
||||
if future, ok := providerRequest["future"].(map[string]any); !ok || future["keep"] != true {
|
||||
t.Fatalf("unknown provider field changed: %+v", providerRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) {
|
||||
var providerRequests []map[string]any
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -420,6 +420,9 @@ func httpStatusForRunError(err error) int {
|
|||
if errors.Is(err, context.Canceled) {
|
||||
return http.StatusRequestTimeout
|
||||
}
|
||||
if status, ok := providerHTTPStatus(err); ok && status == http.StatusBadRequest {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -276,6 +276,9 @@ func TestWorkspaceCommandEncodingAndGuards(t *testing.T) {
|
|||
if !strings.Contains(first.commandString, "'\\''") {
|
||||
t.Fatalf("command does not safely quote apostrophe: %q", first.commandString)
|
||||
}
|
||||
if !strings.HasPrefix(first.commandString, first.containmentGuard+" && ") {
|
||||
t.Fatalf("caller command does not execute its containment guard: %q", first.commandString)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-escape guard is concrete and unsafe paths fail before caller execution", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ func encodeWorkspaceCall(b *workspaceBinding, op workspaceOperationKind, call no
|
|||
providerCallID: strings.TrimSpace(call.ProviderCallID),
|
||||
safePath: safePath,
|
||||
}
|
||||
payload.containmentGuard = synthesizeContainmentGuard(safePath, ob.createsParents)
|
||||
|
||||
switch ob.mode {
|
||||
case modeStructured:
|
||||
|
|
@ -135,8 +136,10 @@ func encodeWorkspaceCall(b *workspaceBinding, op workspaceOperationKind, call no
|
|||
default:
|
||||
return nil, fmt.Errorf("unknown binding mode %q", ob.mode)
|
||||
}
|
||||
|
||||
payload.containmentGuard = synthesizeContainmentGuard(safePath, ob.createsParents)
|
||||
if ob.mode == modeCommand {
|
||||
payload.commandString = payload.containmentGuard + " && " + payload.commandString
|
||||
payload.structuredArgs[ob.commandField] = payload.commandString
|
||||
}
|
||||
payload.correlationDigest = computePayloadCorrelationDigest(payload)
|
||||
if payload.correlationDigest == "" {
|
||||
return nil, fmt.Errorf("issued payload cannot be canonically correlated")
|
||||
|
|
|
|||
|
|
@ -553,31 +553,106 @@ nodes:
|
|||
# Agent-facing aliases use an unmarked light preset plus admitted caller tools.
|
||||
# Do not add single_request or a raw workspace path to this preset; the caller's
|
||||
# workspace_tools alternative binds the caller-opened workspace at request time.
|
||||
# Keep the fixed Node-owned form on a different public alias; never point both
|
||||
# ownership modes at the same preset.
|
||||
# models:
|
||||
# - id: "gpt-hybrid"
|
||||
# display_name: "GPT Hybrid (caller workspace)"
|
||||
# execution_preset: "preset-caller-gpt-hybrid"
|
||||
# - id: "gpt-hybrid-fixed"
|
||||
# display_name: "GPT Hybrid (fixed workspace)"
|
||||
# execution_preset: "preset-fixed-gpt-hybrid"
|
||||
# execution_presets:
|
||||
# - id: "preset-caller-hybrid"
|
||||
# - id: "preset-caller-gpt-hybrid"
|
||||
# selector:
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# allowed_modes: ["light"]
|
||||
# routes:
|
||||
# light:
|
||||
# stages:
|
||||
# - role: "work"
|
||||
# model: "qwen3.6:35b"
|
||||
# - role: "local"
|
||||
# model: "ornith-fast"
|
||||
# - role: "review"
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# workspace_tools:
|
||||
# # Ownership-only excerpt: replace operations with one complete closed
|
||||
# # read/write/delete/prepare mapping for the caller's actual tool schemas.
|
||||
# - name: "caller-workspace"
|
||||
# operations: {}
|
||||
# - name: "opencode-bash"
|
||||
# operations:
|
||||
# prepare:
|
||||
# tool_name: "bash"
|
||||
# creates_parents: true
|
||||
# schema_matcher:
|
||||
# type: "object"
|
||||
# properties:
|
||||
# command: { type: "string" }
|
||||
# argument_map:
|
||||
# path: "path"
|
||||
# command: "command"
|
||||
# argv:
|
||||
# - "python3"
|
||||
# - "-c"
|
||||
# - "import json,os,sys; os.makedirs(sys.argv[1], exist_ok=True); print(json.dumps(dict(prepared=True)))"
|
||||
# - "{path}"
|
||||
# result_matcher: { status: "success" }
|
||||
# read:
|
||||
# tool_name: "bash"
|
||||
# schema_matcher:
|
||||
# type: "object"
|
||||
# properties:
|
||||
# command: { type: "string" }
|
||||
# argument_map:
|
||||
# path: "path"
|
||||
# command: "command"
|
||||
# argv:
|
||||
# - "python3"
|
||||
# - "-c"
|
||||
# - "import json,sys; print(json.dumps(dict(content=open(sys.argv[1], encoding='utf-8').read())))"
|
||||
# - "{path}"
|
||||
# result_matcher: { status: "success" }
|
||||
# write:
|
||||
# tool_name: "bash"
|
||||
# creates_parents: true
|
||||
# schema_matcher:
|
||||
# type: "object"
|
||||
# properties:
|
||||
# command: { type: "string" }
|
||||
# argument_map:
|
||||
# path: "path"
|
||||
# content: "content"
|
||||
# command: "command"
|
||||
# argv:
|
||||
# - "python3"
|
||||
# - "-c"
|
||||
# - "import json,os,sys; p=sys.argv[1]; d=os.path.dirname(p); d and os.makedirs(d, exist_ok=True); open(p, 'w', encoding='utf-8').write(sys.argv[2]); print(json.dumps(dict(written=True)))"
|
||||
# - "{path}"
|
||||
# - "{content}"
|
||||
# result_matcher: { status: "success" }
|
||||
# delete:
|
||||
# tool_name: "bash"
|
||||
# schema_matcher:
|
||||
# type: "object"
|
||||
# properties:
|
||||
# command: { type: "string" }
|
||||
# argument_map:
|
||||
# path: "path"
|
||||
# command: "command"
|
||||
# argv:
|
||||
# - "python3"
|
||||
# - "-c"
|
||||
# - "import json,os,shutil,sys; p=sys.argv[1]; shutil.rmtree(p) if os.path.isdir(p) and not os.path.islink(p) else os.remove(p); print(json.dumps(dict(deleted=True)))"
|
||||
# - "{path}"
|
||||
# result_matcher: { status: "success" }
|
||||
#
|
||||
# === Fixed single-request preset example (commented) ===
|
||||
# execution_presets[] entry with operator-owned fixed single-request policy.
|
||||
# Live-apply on refresh; affects only new request snapshots.
|
||||
# execution_presets:
|
||||
# - id: "preset-fixed-light"
|
||||
# - id: "preset-fixed-gpt-hybrid"
|
||||
# selector:
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# allowed_modes:
|
||||
|
|
@ -586,13 +661,13 @@ nodes:
|
|||
# light:
|
||||
# stages:
|
||||
# - role: "plan"
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# - role: "work"
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "ornith-fast"
|
||||
# - role: "review"
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# single_request:
|
||||
|
|
@ -604,13 +679,13 @@ nodes:
|
|||
# max_output_bytes: 16777216 # 16 MiB per stage
|
||||
# stages:
|
||||
# plan:
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# work:
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "ornith-fast"
|
||||
# review:
|
||||
# model: "qwen3.6:35b"
|
||||
# model: "gpt-5.6-terra"
|
||||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# templates:
|
||||
|
|
|
|||
Loading…
Reference in a new issue