diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 3716771ad..d9ca20e6e 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -58,14 +58,18 @@ func newView(probe Config, info RuntimeInfo) (*agent.View, error) { if err != nil { return nil, err } - view := declareView(probe, node, root, filepath.ToSlash(bridge), filepath.ToSlash(info.NativePath), loader) + view := declareView(probe, info, node, root, filepath.ToSlash(bridge), loader) if err := view.Validate(); err != nil { return nil, err } return view, nil } -func declareView(probe Config, node, root, bridge, native string, loader viewloader.Fragment) *agent.View { +// declareView declares the view of the install that info describes. The view +// runs the bridge in workspace mode, so its functions follow the probe as a +// workspace Runtime's do. +func declareView(probe Config, info RuntimeInfo, node, root, bridge string, loader viewloader.Fragment) *agent.View { + native := filepath.ToSlash(info.NativePath) nodeMount := agent.ViewMount{Name: "node", HostDir: filepath.Dir(node)} bundle := agent.ViewMount{Name: "claude-sdk", HostDir: root} layout := viewLayout{node: nodeMount.Path() + "/" + filepath.Base(node), bridge: bundle.Path() + "/" + bridge, libraries: loader.LibraryPath} @@ -81,9 +85,9 @@ func declareView(probe Config, node, root, bridge, native string, loader viewloa Capabilities: agent.ViewCapabilities{ EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()), + FunctionResultImages: proto.CapabilityFromBool(info.SupportsFunctionResultImages()), + ToolSearch: proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()), StdioMCP: proto.CapabilityUnsupported, }, } diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 1d2a567b2..573aeb21c 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -150,7 +150,7 @@ func resolveTestView(t *testing.T) agent.View { } lib := agent.ViewMount{Name: viewloader.MountName, HostDir: filepath.Join(root, "lib")} loader := viewloader.Fragment{Closure: []agent.ViewMount{lib}, Overlays: []agent.ViewOverlay{{Path: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), Exec: true}}, LibraryPath: lib.Path()} - declared := declareView(probe, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", "native/claude", loader) + declared := declareView(probe, RuntimeInfo{NativePath: "native/claude"}, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", loader) registry := agent.NewRegistry() registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, Session: NewFactory(probe), View: declared}) view, err := registry.ResolveView(Declaration.Info.Kind) diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 144b16ed6..3c3872b8a 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -89,8 +89,8 @@ func newView(binary string, codeModeHost bool) agent.View { Capabilities: agent.ViewCapabilities{ EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilitySupported, + FunctionResultImages: proto.CapabilitySupported, ToolSearch: proto.CapabilityUnsupported, StdioMCP: proto.CapabilityUnsupported, }, diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index a02b8ef25..5478627f1 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -6,14 +6,18 @@ package agenthostqualify import ( + "bytes" "context" "crypto/rand" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" + "image" + "image/png" "io" "log/slog" "math/big" @@ -107,15 +111,19 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { t.Fatalf("%s declares no agent-host view; discovery reported why above", kind) } reg.Register(declaration, *runtime) - qualify(t, h, cfg, sb, kind, sessionModel(t, raw, key)) + qualify(t, h, cfg, sb, kind, runtime.View.Capabilities, sessionModel(t, raw, key)) }) } } -// qualify runs one Turn that writes a file, runs a failing command and -// reports what it printed and its exit status, then checks all three. Only -// the sandbox's tool environment holds the value and the status. -func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, model proto.PromptRequestPayload) { +// qualify runs the kind's Turns through dispatch. The first writes a file, +// runs a failing command and reports what it printed and its exit status, +// then the test checks all three; only the sandbox's tool environment holds +// the value and the status. A view that declares function tools runs a second +// Turn in a new Executor, which resumes the Session's native history, and +// calls a function there. A view that declares tool search runs a Turn in +// another Session that finds the function, deferred, with tool search. +func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps agent.ViewCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) code, _ := rand.Int(rand.Reader, big.NewInt(90)) @@ -126,43 +134,136 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, " It prints a value and exits with a non-zero status; that is expected.\n" + "3. Answer with exactly one line: VALUE= EXIT=" - // dispatch drives the Session's Turn through the agent host's Executor. - b := sb.binding() - sb.grant(b, cfg.RuntimeID) env := agenthost.Environment{ Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, } + configuration := proto.PromptRequestPayload{AgentKind: kind, StrictResume: true, DisableSubagents: true, + Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}} + if caps.FunctionTools.IsSupported() { + configuration.FunctionTools = []proto.FunctionTool{lookupTicket} + } + k := newTicket(t) + s := sb.session(h, cfg, env, configuration) + done, _ := s.turn(t, "qualify", prompt, k) + if !strings.Contains(done.Content, "VALUE="+value) || !strings.Contains(done.Content, fmt.Sprintf("EXIT=%d", exit)) { + t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", done.Content, value, exit) + } + if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { + t.Errorf("%s holds %q, want %q", name, got, content) + } + + if caps.FunctionTools.IsSupported() { + // The first Executor retired with its Router. + native, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string) + if native == "" { + t.Fatal("the first Turn reported no native session to resume") + } + s.configuration.AgentSessionID = native + done, calls := s.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) + if resumed, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); resumed != native { + t.Errorf("the resumed Turn reported the native session %q, want %q", resumed, native) + } + k.check(t, done, calls) + } + if caps.ToolSearch.IsSupported() { + deferred := lookupTicket + deferred.DeferLoading = true + configuration.ToolSearch, configuration.FunctionTools = true, []proto.FunctionTool{deferred} + search := sb.session(h, cfg, env, configuration) + done, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) + k.check(t, done, calls) + } +} + +// lookupTicket is the function the function Turns call. +var lookupTicket = proto.FunctionTool{Name: "lookup_ticket", Description: "Looks up a support ticket by its number.", + Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string","description":"The ticket number"}},"required":["ticket"],"additionalProperties":false}`)} + +// ticket is lookup_ticket's result for number: a text with first, an image, +// and a text with second. An answer that holds both codes shows that the +// Harness gave its model the whole result. +type ticket struct{ number, first, second, image string } + +func newTicket(t *testing.T) ticket { + var encoded bytes.Buffer + if err := png.Encode(&encoded, image.NewGray(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + number, _ := rand.Int(rand.Reader, big.NewInt(9000)) + return ticket{number: fmt.Sprint(number.Int64() + 1000), first: strings.ToLower(rand.Text()[:8]), second: strings.ToLower(rand.Text()[:8]), + image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())} +} + +func (k ticket) result() []proto.InputContent { + first, second := "Ticket "+k.number+": the first code is "+k.first+".", "The second code is "+k.second+"." + return []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_image", ImageURL: &k.image}, {Type: "input_text", Text: &second}} +} + +// prompt asks for one call of the function that find finds. +func (k ticket) prompt(find string) string { + return find + ", and call it once with ticket \"" + k.number + "\".\nAnswer with exactly one line: FIRST= SECOND=" +} + +// check checks that the Turn called lookup_ticket for the ticket and +// answered with both codes. +func (k ticket) check(t *testing.T, done proto.DonePayload, calls []proto.FunctionCallPayload) { + t.Helper() + if len(calls) == 0 || calls[0].Name != lookupTicket.Name || !strings.Contains(string(calls[0].Arguments), k.number) { + t.Errorf("the Turn made %d function calls, want the first to call %s for ticket %s", len(calls), lookupTicket.Name, k.number) + } + if !strings.Contains(done.Content, "FIRST="+k.first) || !strings.Contains(done.Content, "SECOND="+k.second) { + t.Errorf("the answer %q does not report FIRST=%s SECOND=%s", done.Content, k.first, k.second) + } +} + +// session is a Session bound to the sandbox. Each Turn runs in a new +// Executor through a new dispatch Router. +type session struct { + h *agenthost.Host + cfg agenthost.Config + binding agenthost.Binding + env agenthost.Environment + id string + configuration proto.PromptRequestPayload +} + +func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthost.Environment, configuration proto.PromptRequestPayload) *session { + s := &session{h: h, cfg: cfg, binding: sb.binding(), env: env, id: uuid.NewString(), configuration: configuration} + s.configuration.AgentStateKey = "agents-api-" + s.id + sb.grant(s.binding, cfg.RuntimeID) + return s +} + +// turn prepares an Executor of the Session, runs prompt as its Turn run, +// answers each function call with k's result, and retires the Executor with +// the Router's Shutdown. It returns the Turn's Done and its function calls. +func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { + t.Helper() out := make(sender, 256) - router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: cfg.Log, - Registry: h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { return b, env, nil })}) + router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, + Registry: s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { + return s.binding, s.env, nil + })}) if err != nil { t.Fatal(err) } - session := uuid.NewString() - handle(t, router, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{ - AgentKind: kind, AgentStateKey: "agents-api-" + session, StrictResume: true, DisableSubagents: true, - Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, - LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}}}) - ready := out.status(t, "prepare") + prepare := "prepare-" + run + handle(t, router, proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: s.id, Configuration: s.configuration}) + ready := out.status(t, prepare) if ready.State != "ready" { t.Fatalf("the preparation is %s (%s), want ready; the log above says why", ready.State, ready.ErrorCode) } - handle(t, router, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, - RunID: "qualify", Input: proto.TextInput(prompt)}) - answer := out.collect(t, "qualify") + handle(t, router, proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, + RunID: run, Input: proto.TextInput(prompt)}) + done, calls := out.collect(t, router, run, k) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := router.Shutdown(ctx); err != nil { t.Errorf("Shutdown: %v", err) } - - if !strings.Contains(answer, "VALUE="+value) || !strings.Contains(answer, fmt.Sprintf("EXIT=%d", exit)) { - t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", answer, value, exit) - } - if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { - t.Errorf("%s holds %q, want %q", name, got, content) - } + return done, calls } func handle(t *testing.T, router *dispatch.Router, typ, id string, payload any) { @@ -214,21 +315,36 @@ func (s sender) status(t *testing.T, id string) proto.PreparationStatusPayload { } } -// collect reads the Turn's envelopes until its Done and returns its content. -func (s sender) collect(t *testing.T, run string) string { +// collect reads the Turn's envelopes until its Done. It answers each function +// call with k's result through router and checks that dispatch applied it. +func (s sender) collect(t *testing.T, router *dispatch.Router, run string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { t.Helper() deadline := time.After(turnLimit) + var calls []proto.FunctionCallPayload for { switch e := s.next(t, run, deadline); e.Type { case proto.TypeError: t.Errorf("Turn error: %s", e.Payload) + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := e.DecodePayload(&call); err != nil { + t.Fatal(err) + } + t.Logf("function call: %s %s", call.Name, call.Arguments) + calls = append(calls, call) + handle(t, router, proto.TypeFunctionResult, run, proto.FunctionResultPayload{DeliveryID: "result-" + call.CallID, CallID: call.CallID, Success: true, Content: k.result()}) + case proto.TypeInteractionDecisionAck: + var ack proto.InteractionDecisionAckPayload + if err := e.DecodePayload(&ack); err != nil || !ack.Applied { + t.Errorf("a function result was not applied: %s", e.Payload) + } case proto.TypeDone: var d proto.DonePayload - if err := json.Unmarshal(e.Payload, &d); err != nil { + if err := e.DecodePayload(&d); err != nil { t.Fatal(err) } t.Logf("answer: %s", d.Content) - return d.Content + return d, calls } } } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index bf62a5367..c39df42bb 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -367,7 +367,7 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | | `Capabilities` | Each supported feature runs a Turn through dispatch: environment none in the empty-root view, Skills, function calls and results, tool search, and each stdio binding under its alias. | -`scripts/qualify-agent-host.sh` runs one Turn per Harness through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. Each Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. +`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the view declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the view declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. ## Native references diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3630b0fa5..3f18bf5fe 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: d62c6b491f137b9cafd254a056c02b285ece3141f26fcd96d290c63210dcf785 +source_hash: b89f8241275419330cf55481c11add0a40b58cf06fa0544ed8e0d7cb3839d3a3 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -369,7 +369,7 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | | `Capabilities` | 每项受支持的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、Skills、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | -`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 为每个 Harness 运行一个 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。每个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。视图声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。视图声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 ## 原生参考 {#native-references}