refactor(execution): remove Eval-owned paid model transports - #679
Merged
Conversation
tangletools
approved these changes
Aug 21, 2026
tangletools
left a comment
Contributor
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — 10602dcd
This PR was opened by the trusted drewstone account.
This approval is provisional and was applied by the local stand-in because the pr-reviewer webhook host is unreachable (2026-08-21). CI on this head is fully green. The full PR reviewer audit re-runs via the resweep when the service returns and will publish findings if it detects issues.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Agent Eval owns comparison, scoring, and durable evidence. It still had several ways to execute a paid model itself, each accepting a provider URL or a credential and issuing provider HTTP from Eval. Every one of those paths let a consumer bypass Runtime, which makes exact
AgentProfileidentity, retries, usage, cache accounting, and interruption safety optional rather than structural.Three issuers existed on
main:src/llm-client.ts—callLlm/callLlmJson/probeLlm/LlmClient, default base URLhttps://router.tangle.tools/v1, reached throughcreateChatClient'srouter/direct-provider/cli-bridgevariants,createOpenAiCompatibleExecutionOwner, the two judges,wire/v1/judge,eval-campaign, and the CLI.src/multishot/router.ts— its ownfetch, plusTANGLE_ROUTER_BASE_URL/TANGLE_API_KEY/JUDGE_MODELdiscovered from the environment.src/integrity/preflight.ts—preflightModels({ baseUrl, apiKey })issuingGET /modelsandPOST /chat/completions.The architectural call and its evidence are on the issue: #539 (comment)
The short version.
agent-runtimealready owns paid execution for this contract:profileOptimizerModelCall(src/runtime/profile-chat-client.ts:60, exported atsrc/runtime/index.ts:336, documented inagent-runtime/docs/improve.md:15,77) implementsExternalOptimizerModelCallagainst one exactAgentProfile, with a profile digest, a request digest, observed-vs-declared model refusal, transport attempts, andusdKnownprovenance. Eval's own contract already says so —src/campaign/external-optimizer-contracts.ts: "For Discovery that owner is Runtime and the identity is an AgentProfile." SocreateOpenAiCompatibleExecutionOwner(added by #651) was a second, strictly weaker implementation of a role Runtime already owned. Two owners for one role is the defect; the fix is deletion, not relocation. No agent-runtime PR is needed for the move.What
Eval accepts a caller-supplied
ChatClient(orMultishotTransport, orExternalOptimizerModelCall, orModelEndpointRequest) and executes nothing.createChatClient:router,direct-provider, andcli-bridgedeleted withwrapLlmClient.custom,sandbox-sdk, andmockremain;ChatTransportnarrows to those three.llm-client: the transport half leaves the published surface.callLlm,callLlmJson,LlmClient,LlmClientOptions,LlmRouteRequirementsare no longer exported from the root barrel, andsrc/llm-client.tsis not an export subpath, so no consumer import can reach them.assertLlmRouteandprobeLlmare deleted — the caller holds the endpoint, so the caller owns both the route check and the reachability probe, and neither had an in-repo caller left. The canonical contract stays public:LlmCallRequest,LlmCallResult(withlogprobs,toolCalls,servedModel),LlmMessage,LlmUsage,costReceiptFromLlm,costReceiptFromLlmError,maximumChargeForLlmRequest,isTransientLlmError,stripFencedJson.DEFAULT_BASE_URL = 'https://router.tangle.tools/v1'is deleted from the internal client andbaseUrlis required. WithassertLlmRoutegone, a default endpoint would be exactly the silent fallback that assertion existed to catch; requiring the field makes the bad state unrepresentable instead of asserted-against. The only remaining literal provider hosts undersrc/are incli-config.ts(the binary's documented env mapping) andtrace/raw-provider-sink.ts(a redaction hostname table).createOpenAiCompatibleExecutionOwnerdeleted from/campaign.examples/_shared/openai-compatible-owner.tsis the caller-side reference implementation and exposes the endpoint two ways —openAiCompatibleChatClientandopenAiCompatibleExecutionOwner.examples/is not inpackage.jsonfiles, so it ships to nobody: the bad state is unrepresentable, not merely discouraged.src/multishot/router.tsdeleted.agentTransportanddriverTransportare required,toolTransportnames the leg the built-in delegate tools run on,JudgeConfig.transportis required,MultishotToolExecutorreceives{ transport, signal }instead of{ apiKey, baseUrl, signal }, andJUDGE_MODELis no longer read from the environment.estimateRouterCost→estimateMultishotCostinmultishot/cost.ts.preflightModels/assertModelsServed: takerequest: ModelEndpointRequest— a caller-owned function answering a{ kind: 'list-models' }or{ kind: 'probe', model, maxOutputTokens }check with aResponse. Passing the rawResponseback is deliberate: it keeps status, the provider's ownerror.message,budgetExhausted, and served-model substitution exactly as detectable as before. A higher-level{ observedModel, receipt }callback would have lost all four.runIntentMatchJudge,runSemanticConceptJudge,handleJudge,dispatchRpc, andcreateApptakechat: ChatClient(+ optionalpricing)./v1/judgerefuses withllm_not_configured(503) when no transport is configured — a stronger replacement for the old route assertion.runEvalCampaigntakeschatFactory: (wiring) => ChatClientand putschaton the run context; the campaign hands the factory each run'srawSinkandrunId, so a transport that binds them still satisfiesassertRunCaptured's raw-coverage requirement. The fingerprint folds a caller-declaredexecutionRefwhere it previously folded a base URL Eval can no longer see.agent-eval serve/rpc/rpc-batchis a deployed process whose caller is a JSON-RPC client in another language; it cannot be handed aChatClient, and deleting it would remove the Python RPC product with no replacement. Sosrc/cli-config.tsis the ONE place in the package that turns an environment credential into a transport, it lives inside the binary, and both README files say so. Both a base URL and a key are now required — a half-configured server refuses instead of calling an unintended endpoint.Capability preserved across the caller boundary
The thing that must not be lost is the execution evidence, and it all rides on
ChatResponse/CostReceiptInput, which are unchanged: model identity (modelplus the separateservedModel, which is the only field that can witness a gateway substitution),timeoutMs, cancellation viaChatCallOpts.signal, a stable per-call id viaidempotencyKey, retry count (maximumAttemptson the client,transportAttemptsinraw), input/output/reasoning/cached usage, billed-or-unknown USD (costUsd: nullandcostUnknown/usageUnknownstay explicit — never a guessed zero), per-tokenlogprobs, and finite JSON execution evidence on the optimizer path.llmJudge({ scoring: { method: 'expectation' } })from #637 still works:logprobsride on the canonical response, so any caller-owned transport can carry them.No source-check script
CUTS.md killed
check-provider-transport.mjsand I agree. The enforcement here is a deletion plus an un-export: the credential-bearing transports do not exist, and the module that still holds one is not reachable through any export subpath. A CI script grepping forprocess.env.*API_KEYafter the fact would be theatre next to that.Downstream breakage, named
docs/public-api.mdrecords real consumers of the removed surface. Every one of them is the bypass this issue exists to close, and each has a named replacement —profileChatClient/profileOptimizerModelCallfrom@tangle-network/agent-runtime/kernel, or acustomChatClientover the client they already have:callLlm,callLlmJson,LlmClient,LlmClientOptionsassertLlmRouteprobeLlmpreflightModelssignaturecreateChatClientrouter/direct/cli-bridgerouterCompletion,requireRouterApiKey,defaultRouterBaseUrl,estimateRouterCostrunMultishot/runJudgerequired transportsexamples/p1-parity/arms.ts(drops its two dummyapiKey/baseUrllines), gtm-agent, tuner-agentagent-runtime/srcimports none of these; only itsexamples/p1-parity/arms.tsdoes, and only the two dummy fields it passes becauserunMultishotused to resolve them eagerly. That example lands when runtime widens itsagent-evalcatalog pin.Left for a follow-up, deliberately
profileOptimizerModelCallforwards onlyreq.messagesintostreamAgentTurn; it dropstools/toolChoiceand never returnstoolCalls, so it cannot yet serve the tool-calling path #666 shipped for theclaudeCLI. Closing it needsrouterChatWithTools-style pass-through threaded throughstreamAgentTurnand is blocked on agent-runtime widening itsagent-evalcatalog pin past 0.150.1, wheretoolswas added toLlmCallRequest. Filed as tangle-network/agent-runtime#927. It is not a prerequisite here: the tool-carrying path is served by a caller-owned owner today.Also left: multishot carries its own four-model price table (
estimateMultishotCost) beside the package's realestimateCost/isModelPriced. Collapsing them changes recorded numbers, so it is a behavior change with its own test, not a rider on a transport removal.Simplification
Simplification: deleted
src/multishot/router.ts,src/campaign/openai-compatible-execution-owner.ts, and the route-assertion +probeLlmblock insrc/llm-client.ts; collapsed the three hand-rolled "reserve, call, settle, parse" copies in the two judges and the wire judge endpoint into onepaidJsonChat(llmJudgekeeps its own because its logprob-expectation scoring reads the response differently — named, not smuggled); collapsed the golden matrix check's process-wideglobalThis.fetchjudge wire into a scripted judge transport, which also removes the module-global install guard and the serial-checks-only constraint it forced.Net: +1948 / -2494 lines (net -546), 77 files, 3 credential-bearing transports removed, 3 duplicated paid-call copies collapsed to 1, 1 process-wide fetch hijack removed.
Not done here: the runtime tool-forwarding gap (agent-runtime#927) and the multishot price-table duplication, both named above.
Tests: +7 (a caller-owned transport still carries cancellation into a judge and settles an incomplete receipt;
/v1/judgerefuses withllm_not_configured503 when no transport is configured; a half-configured CLI route resolves to nothing rather than an unintended endpoint; the CLI binds its resolved route into the transport the wire handlers take; a model answer that is not JSON keeps its settled cost instead of reporting the spend as unknown — that one caught a real regression in this PR, where routing the judges through one helper had moved the parse failure outsiderunPaidCalland dropped the receipt), -30 deleted (openai-compatible-execution-owner.test.tswhole file,tests/llm-route-assertion.test.tswhole file, theprobeLlmblock inllm-client.test.ts, the golden matrix judge-wire serialization guard, theassertLlmRoutecampaign smokenot.toThrow, the campaign's route-assertion refusal, thejson_schema->json_objectdegrade test, and the multishot "zero HTTP with both seams injected" test — every one of them existed only to exercise a transport that no longer ships)Proof
All run on
agent-eval@10602dcd, rebased onorigin/main@cd06dc95(0.159.1 -> 0.160.0), macOS, node 24.11.1. CI on Linux passed both jobs (ci,gepa-release) on the first commit of this branch.Full suite, this branch vs a clean
origin/mainworktree, same machine, same run:Failures unique to this branch: 1, and it is a load flake, not a regression —
external optimizer process terminates the detached process group promptly when the caller aborts.src/campaign/external-optimizer-process.tsandexternal-optimizer-subprocess.tsare untouched by this branch (git diff --name-onlyagainst the base: empty), that same file already fails a different case on clean main under full-suite load, and the case passes 3/3 in isolation on this branch. The rest sit in the same files —analyst/benchmark-command*.test.ts,analyst/benchmark-verification-artifacts.test.ts,tests/campaign/external-optimizer-process.test.ts,tests/campaign/worktree.test.ts— the documented local-sandbox set (git-worktree adapters, process-spawn timeouts) that fails identically on clean main; the two extra on main are known subprocess flakes. CI on Linux is the authority.Behavior-preservation proof for the two riskiest areas:
multishot golden records unchanged.
src/multishot/golden/golden.test.ts53/53 pass with the frozen records untouched, after the agent, driver, tool, and now judge legs all run on scripted transports. The judge leg previously hijackedglobalThis.fetch; it now uses a scriptedMultishotTransportthat returns the samecostUsd: 0.0007the old_response_costproduced, so the recorded judge ledger is byte-identical.execution evidence survives the caller boundary.
src/reference-equivalence-judge.test.ts16/16, including the rewritten cancellation case: a caller-owned transport receives the campaign'sAbortSignal, the abort propagates, and the cost ledger still records{ costUnknown: true, usageUnknown: true, error }rather than a zero-cost success.The published surface, read off the built artifact (
import('./dist/index.js')):callLlm,callLlmJson,LlmClient,probeLlm,assertLlmRoute— all absent.createChatClient,preflightModels,costReceiptFromLlm,maximumChargeForLlmRequest,isTransientLlmError,stripFencedJson,llmJudge— all present./campaignno longer exportscreateOpenAiCompatibleExecutionOwner;/multishotno longer exportsrouterCompletion,requireRouterApiKey,defaultRouterBaseUrl, orestimateRouterCost, and does exportestimateMultishotCost.The product path still works, proven on the built artifact.
node dist/cli.js rpc judgeagainst a local OpenAI-compatible stub:Note
"model":"fake-judge-model"— the id the endpoint echoed, not the id requested. Served-model identity survives the new boundary.The digest gates moved only their live constants.
ANALYST_BENCHMARK_IMPLEMENTATION_SHA256andANALYST_BENCHMARK_DEPENDENCY_LOCK_SHA256are updated by name;ANALYST_BENCHMARK_EVIDENCE_DEPENDENCY_LOCK_SHA256andANALYST_BENCHMARK_EVIDENCE_IMPLEMENTATION_SHA256are historical facts about published evidence and are untouched.src/analyst/benchmark-reference-result.test.ts5/5.Closes #539