Merge v3-t65: provider-specific proxy transports and catalog prices (T65)

This commit is contained in:
2026-11-07 21:24:36 +00:00
11 changed files with 58919 additions and 892 deletions
+25 -1
View File
@@ -19,7 +19,31 @@ Without that provider, the definition or default family selects the model.
No login fallback runs.
Existing registered models retain their transport.
New logical model references reuse the host's OpenAI-compatible proxy provider and its auth resolver.
New model references reuse a template from their own registered provider and retain its proxy auth resolver.
They never inherit another provider's transport. Unknown providers fail instead of silently using OpenAI.
Models absent from the catalog receive zero prices, not the template's prices.
The extension never reads a credential or registers a direct provider SDK.
## Host proxy transports and prices
`host/js/proxy-models.ts` reads the authenticated `/v1/models` catalog once at host startup.
OpenAI models use pi-ai's Responses transport. Anthropic models use its Messages transport.
The catalog endpoint determines each model's base URL.
For Anthropic, effort capabilities select adaptive thinking and its advertised effort levels.
Both providers use the existing `llm-proxy` key and usage tag. No provider login or direct endpoint fallback runs.
If the catalog fails or exceeds five seconds, a small table supplies these defaults:
| Provider/model | API | Proxy path |
| --- | --- | --- |
| `openai/gpt-6.1-sol` | Responses | `/v1/responses` |
| `anthropic/claude-opus-5-5` | Messages, adaptive thinking | `/apis/anthropic/v1/messages` |
Catalog prices use USD per million tokens, with conversion from `unit_tokens` when necessary.
The host uses the first advertised route's quote, including cache reads and cache writes.
These costs are estimates, not billing totals. Another proxy route can charge different prices.
Unavailable prices remain zero. Catalog changes apply after a host restart.
World family edits still apply at the next run boundary without a restart.
`host/tests/proxy_models.rs` tests the real transports against a local fake proxy.
`host/tests/core_ext.rs` tests these services through the snapshot loader.
+4 -3
View File
@@ -28,14 +28,15 @@ export default function models(host: any) {
const found = discoverAgents(files, '/src/sausage', root, families, refs)
return {families, ...found}
}
const transport = host.models().getProvider('openai')
const template = host.models().getModels('openai')[0]
const register = (ref: any) => {
const store = host.models()
if (store.getModel(ref.provider, ref.modelId)) return
const transport = store.getProvider(ref.provider)
const template = store.getModels(ref.provider)[0]
if (!transport || !template) throw new Error(`No proxy transport for ${ref.provider}/${ref.modelId}`)
const prior = store.getModels(ref.provider)
store.setProvider({...transport, id:ref.provider, getModels:()=>[...prior, {...template, provider:ref.provider, id:ref.modelId, name:ref.modelId}]})
store.setProvider({...transport, getModels:()=>[...prior, {...template, id:ref.modelId, name:ref.modelId,
cost:{input:0,output:0,cacheRead:0,cacheWrite:0}}]})
}
const apply = async (tx: any, id: number) => {
const state = await tx.doc(AgentDoc, id)
+17305 -866
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -31693,6 +31693,8 @@ var faux = fauxProvider({ tokensPerSecond: 1e5 });
models.setProvider(faux.provider);
var proxyTemplate = { ...models.getModels("faux")[0], provider: "openai", baseUrl: "https://proxy.invalid/v1" };
models.setProvider({ ...faux.provider, id: "openai", getModels: () => [proxyTemplate] });
models.setProvider({ ...faux.provider, id: "anthropic", getModels: () => [{ ...proxyTemplate, provider: "anthropic", baseUrl: "https://proxy.invalid/apis/anthropic" }] });
var proxyAuth = models.getProvider("openai").auth;
var storage = await openStorage(globalThis.storagePath);
var registry = createRegistry();
var harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() {
@@ -31743,10 +31745,14 @@ try {
faux.setResponses([fauxAssistantMessage("second")]);
await (await worker.submit({ type: "input", content: "second" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
assert((await harness.snapshot(AgentDoc, worker.id, BACKGROUND_CONTEXT)).model.modelId === "second", "edited family changes next model without restart");
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "logical/new" } }));
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "openai/new" } }));
faux.setResponses([fauxAssistantMessage("proxy")]);
await (await worker.submit({ type: "input", content: "proxy" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
assert(models.getModel("logical", "new").baseUrl === proxyTemplate.baseUrl && models.getProvider("logical").auth === models.getProvider("openai").auth, "new model retains proxy transport and auth");
assert(models.getModel("openai", "new").baseUrl === proxyTemplate.baseUrl && models.getModel("openai", "new").cost.input === 0 && models.getProvider("openai").auth === proxyAuth, "new model retains proxy transport and auth");
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "anthropic/new" } }));
faux.setResponses([fauxAssistantMessage("anthropic")]);
await (await worker.submit({ type: "input", content: "anthropic" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
assert(models.getModel("anthropic", "new").baseUrl === "https://proxy.invalid/apis/anthropic", "new Anthropic model retains its own provider transport");
globalThis.modelPin = { provider: "faux", modelId: "faux-1" };
faux.setResponses([fauxAssistantMessage("pin")]);
await (await worker.submit({ type: "input", content: "pin" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
+8 -2
View File
@@ -9,6 +9,8 @@ const assert = (ok:any,message:string)=>{if(!ok)throw new Error(message);Deno.co
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const proxyTemplate={...models.getModels('faux')[0],provider:'openai',baseUrl:'https://proxy.invalid/v1'}
models.setProvider({...faux.provider,id:'openai',getModels:()=>[proxyTemplate]})
models.setProvider({...faux.provider,id:'anthropic',getModels:()=>[{...proxyTemplate,provider:'anthropic',baseUrl:'https://proxy.invalid/apis/anthropic'}]})
const proxyAuth=models.getProvider('openai').auth
const storage=await openStorage(globalThis.storagePath), registry=createRegistry()
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
@@ -56,10 +58,14 @@ try {
faux.setResponses([fauxAssistantMessage('second')])
await (await worker.submit({type:'input',content:'second'},context)).wait(context)
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited family changes next model without restart')
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'logical/new'}}))
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'openai/new'}}))
faux.setResponses([fauxAssistantMessage('proxy')])
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
assert(models.getModel('logical','new').baseUrl===proxyTemplate.baseUrl && models.getProvider('logical').auth===models.getProvider('openai').auth,'new model retains proxy transport and auth')
assert(models.getModel('openai','new').baseUrl===proxyTemplate.baseUrl && models.getModel('openai','new').cost.input===0 && models.getProvider('openai').auth===proxyAuth,'new model retains proxy transport and auth')
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'anthropic/new'}}))
faux.setResponses([fauxAssistantMessage('anthropic')])
await (await worker.submit({type:'input',content:'anthropic'},context)).wait(context)
assert(models.getModel('anthropic','new').baseUrl==='https://proxy.invalid/apis/anthropic','new Anthropic model retains its own provider transport')
globalThis.modelPin={provider:'faux',modelId:'faux-1'}
faux.setResponses([fauxAssistantMessage('pin')])
await (await worker.submit({type:'input',content:'pin'},context)).wait(context)
+3 -17
View File
@@ -1,7 +1,6 @@
// Durable root conversation and console. SQLite stays outside the world.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { proxyModels } from './proxy-models';
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from './environment';
import { openStorage } from './sqlite';
@@ -12,21 +11,8 @@ import { harnessApi } from './harness-api';
import { loadExtensions } from './extensions';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
const models = createModels();
const provider = openaiProvider();
const model = {
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'],
thinkingLevelMap: { off: null, minimal: null },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
};
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl,
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
source: 'llm-proxy',
}) } },
});
const models = await proxyModels(proxy);
const model = models.getModel('openai', 'gpt-6.1-sol');
const registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js" },
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild proxy-models-test.ts --bundle --format=esm --platform=browser --outfile=proxy-models-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
/** Exercise both real pi-ai transports and their catalog prices, without provider SDK mocks. */
import { proxyModels } from './proxy-models';
const models = await proxyModels(globalThis.testProxy);
for (const [provider, id] of [['openai', 'gpt-6.1-sol'], ['anthropic', 'claude-opus-5-5']]) {
const model = models.getModel(provider, id);
if (model.cost.input !== 2 || model.cost.cacheWrite !== 3) throw new Error('catalog prices lost');
const result = await models.completeSimple(model, {messages: [{role: 'user', content: 'Say hello.', timestamp: Date.now()}]}, {reasoning: 'low', maxTokens: 128});
if (result.stopReason === 'error' || !result.content.some(block => block.type === 'text' && block.text === 'hello')) {
throw new Error(`${provider}: ${result.errorMessage ?? JSON.stringify(result.content)}`);
}
if (!(result.usage.cost.total > 0)) throw new Error('turn cost missing');
}
const fallback = await proxyModels({...globalThis.testProxy, base_url: `${globalThis.testProxy.base_url}/offline`});
for (const [provider, api] of [['openai', 'openai-responses'], ['anthropic', 'anthropic-messages']]) {
const model = fallback.getModels(provider)[0];
if (model.api !== api || model.cost.input !== 0) throw new Error('offline transport fallback failed');
}
+47
View File
@@ -0,0 +1,47 @@
/** Host-only proxy catalog, provider transports, authentication, and quoted token prices. */
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
export async function proxyModels(proxy: {base_url: string, api_key: string}) {
const models = createModels();
const base = proxy.base_url.replace(/\/$/, '');
let catalog: any[] = [];
try {
const response = await fetch(`${base}/v1/models`, {
headers: {Authorization: `Bearer ${proxy.api_key}`}, signal: AbortSignal.timeout(5000),
});
if (response.ok) catalog = (await response.json()).data ?? [];
} catch { /* An unavailable catalog must not prevent durable recovery. */ }
for (const [provider, api, path, fallback] of [
[openaiProvider(), 'openai-responses', '/v1/responses', 'gpt-6.1-sol'],
[anthropicProvider(), 'anthropic-messages', '/apis/anthropic/v1/messages', 'claude-opus-5-5'],
] as const) {
const rows = catalog.filter(row => row.creator === provider.id && row.endpoints?.some((endpoint: any) => endpoint.api === api));
const entries = (rows.length ? rows : [{id: fallback}]).map(row => {
const endpoint = row.endpoints?.find((endpoint: any) => endpoint.api === api)?.path ?? path;
// pi-ai appends /responses or /v1/messages, respectively.
const suffix = api === 'anthropic-messages' ? '/v1/messages' : '/responses';
const baseUrl = `${base}${endpoint.slice(0, -suffix.length)}`;
const price = row.providers?.[0]?.pricing;
const scale = price?.currency === 'USD' ? 1000000 / price.unit_tokens : 0;
return {
id: row.id, name: row.name ?? row.id, provider: provider.id, api, baseUrl,
reasoning: row.capabilities?.reasoning ?? true, input: ['text'],
thinkingLevelMap: {off: null, minimal: null,
...Object.fromEntries((row.capabilities?.reasoning_options?.find((option: any) => option.type === 'effort')?.values ?? []).map((level: string) => [level, level]))},
compat: api === 'anthropic-messages' ? {forceAdaptiveThinking: row.capabilities?.reasoning_options?.some((option: any) => option.type === 'effort') ?? true} : undefined,
cost: {input: (price?.input ?? 0) * scale, output: (price?.output ?? 0) * scale,
cacheRead: (price?.cached_input ?? 0) * scale, cacheWrite: (price?.cache_write ?? 0) * scale},
contextWindow: row.limits?.context_window ?? 200000, maxTokens: 4096,
};
});
models.setProvider({...provider, baseUrl: entries[0].baseUrl, getModels: () => entries as any,
auth: {apiKey: {name: 'Sausage proxy', resolve: async () => ({
auth: {apiKey: proxy.api_key, headers: {'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage'])}},
source: 'llm-proxy',
})}},
});
}
return models;
}
+107
View File
@@ -0,0 +1,107 @@
//! The host proxy catalog selects distinct pi-ai transports with shared proxy authentication.
use sausage::{
exec::{Command, Exec, Options, Stream},
world::World,
};
use std::{sync::Arc, time::Duration};
struct NoExec;
#[async_trait::async_trait]
impl Exec for NoExec {
async fn exec(
&self,
_: Command,
_: Options,
_: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
) -> anyhow::Result<i32> {
anyhow::bail!("unexpected world command")
}
}
fn event(name: &str, value: serde_json::Value) -> String {
format!("event: {name}\ndata: {value}\n\n")
}
#[tokio::test(flavor = "current_thread")]
async fn provider_transport_and_prices() -> anyhow::Result<()> {
use serde_json::json;
// Production initializes this in the process runtime; this fixture uses NoExec.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let server = tiny_http::Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e))?;
let base = format!("http://{}", server.server_addr());
let proxy = json!({"base_url":base,"api_key":"test-proxy-key"});
let thread = std::thread::spawn(move || -> anyhow::Result<()> {
for path in [
"/v1/models",
"/v1/responses",
"/apis/anthropic/v1/messages?beta=true",
"/offline/v1/models",
] {
let mut request = server
.recv_timeout(Duration::from_secs(20))?
.ok_or_else(|| anyhow::anyhow!("request timeout"))?;
assert_eq!(request.url(), path);
let authenticated = request.headers().iter().any(|h| {
(h.field.equiv("Authorization") && h.value.as_str() == "Bearer test-proxy-key")
|| (h.field.equiv("x-api-key") && h.value.as_str() == "test-proxy-key")
});
assert!(authenticated, "missing proxy key");
if path == "/offline/v1/models" {
request.respond(tiny_http::Response::empty(503))?;
continue;
}
let (body, mime) = if path == "/v1/models" {
let rows: Vec<_> = [("openai", "gpt-6.1-sol", "openai-responses", "/v1/responses"),
("anthropic", "claude-opus-5-5", "anthropic-messages", "/apis/anthropic/v1/messages")]
.into_iter().map(|(creator,id,api,path)|json!({"creator":creator,"id":id,"endpoints":[{"api":api,"path":path}],
"providers":[{"pricing":{"currency":"USD","unit_tokens":1000,"input":0.002,"output":0.008,"cached_input":0.0001,"cache_write":0.003}}]})).collect();
(json!({"data":rows}).to_string(), "application/json")
} else {
let mut body = String::new();
request.as_reader().read_to_string(&mut body)?;
let body: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(body["stream"], true);
assert!(
request
.headers()
.iter()
.any(|h| h.field.equiv("Shopify-Usage-Tag")
&& h.value.as_str() == "[\"pi\",\"sausage\"]")
);
let stream = if path == "/v1/responses" {
assert_eq!(body["model"], "gpt-6.1-sol");
[
event("response.output_item.added", json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"m","role":"assistant","content":[]}})),
event("response.content_part.added", json!({"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}})),
event("response.output_text.delta", json!({"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"hello"})),
event("response.completed", json!({"type":"response.completed","response":{"id":"r","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":1,"total_tokens":11}}})),
].concat()
} else {
assert_eq!(body["model"], "claude-opus-5-5");
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["output_config"]["effort"], "low");
[
event("message_start", json!({"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10,"output_tokens":0}}})),
event("content_block_start", json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})),
event("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}})),
event("content_block_stop", json!({"type":"content_block_stop","index":0})),
event("message_delta", json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}})),
event("message_stop", json!({"type":"message_stop"})),
].concat()
};
(stream, "text/event-stream")
};
request.respond(
tiny_http::Response::from_string(body)
.with_header(tiny_http::Header::from_bytes("Content-Type", mime).unwrap()),
)?;
}
Ok(())
});
let dir = tempfile::tempdir()?;
let source = format!(
"globalThis.testProxy = {proxy};\n{}",
include_str!("../js/proxy-models-test-bundle.js")
);
let result =
sausage::harness::run_module(World::open(dir.path())?, Arc::new(NoExec), &source).await;
thread.join().unwrap()?;
result
}