Merge v3-t65: provider-specific proxy transports and catalog prices (T65)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user