Merge branch 'v3' into v3-t103

This commit is contained in:
2026-11-12 19:45:59 +00:00
12 changed files with 43712 additions and 42910 deletions
+3
View File
@@ -1,5 +1,8 @@
# Agents can edit provider settings. Provider API keys are visible inside the world.
# Empty keys allow startup. Fill the matching provider key before a model request.
# catalog selects a provider ID from pi-ai's catalog. It defaults to the provider's name.
# Model overrides retain unspecified catalog fields, including API flags and price tiers.
# Example: catalog: anthropic with models: [{id: claude-opus-5-5, context_window: 272000}].
providers:
anthropic:
type: anthropic-messages
+5 -3
View File
@@ -187,11 +187,13 @@ Run `sausage init`. Set `providers.<name>.api_key` in the config, then run `saus
The defaults use Anthropic Messages and OpenAI Responses, with empty keys.
The config also shows a commented OpenAI-compatible Chat Completions endpoint.
Each provider has `type`, `base_url`, `api_key`, and `models`. References use `<provider>/<model>`.
An optional `catalog` selects the models.dev provider ID. It defaults to the configured provider's name.
The host refreshes models.dev metadata at startup and caches it in `host/models-dev.json`.
If the refresh fails, the host uses that cache or the packaged snapshot.
An optional `catalog` selects a provider ID from pi-ai's catalog. It defaults to the configured provider's name.
Each model retains pi-ai's complete catalog entry, including API flags, thinking mappings, input limits, and price tiers.
The host needs no separate metadata download, cache, or snapshot.
Model objects can override `context_window`, `max_tokens`, `reasoning`, and `cost`.
Cost fields are `input`, `output`, `cache_read`, and `cache_write`, in USD per million tokens.
Overrides retain unspecified catalog fields. The base URL always comes from the config.
For example, `catalog: anthropic` with an Opus 5.5 context override retains adaptive thinking.
An unknown model needs all metadata fields. Its error names the missing fields and config path.
Pi-ai stores estimated call costs in assistant usage.
+5 -4
View File
@@ -28,18 +28,19 @@ The optional `models.pin` service supplies `pin(tx, conversationId)` for an exte
Each provider has `type`, `base_url`, `api_key`, and `models`.
Supported API types are `anthropic-messages`, `openai-responses`, and `openai-chat-completions`.
Model references use `<provider>/<model>`.
The optional `catalog` field selects a models.dev provider ID. It defaults to the configured provider's name.
The optional `catalog` field selects a provider ID from pi-ai's catalog. It defaults to the configured provider's name.
Authentication and the base URL come from the config before each request.
Missing keys and unknown providers produce errors that name the config file.
Provider API keys in the config are intentionally visible to agents. Do not print or commit them.
The host refreshes `https://models.dev/api.json` at startup and caches it in `host/models-dev.json`.
A failed refresh uses the cache or the packaged snapshot.
Each model starts with pi-ai's complete catalog entry. No separate download, cache, or snapshot is required.
The entry retains API compatibility flags, thinking level mappings, prompt caching, input limits, input types, and price tiers.
Each model entry is a string ID or an object with `id` and metadata overrides.
Supported overrides are `context_window`, `max_tokens`, `reasoning`, and `cost`.
Cost fields are `input`, `output`, `cache_read`, and `cache_write`, in USD per million tokens.
An unknown model needs all metadata fields. Its error lists missing fields and the config path.
An override retains all catalog values that it does not replace.
An override retains all catalog values that it does not replace. The base URL always comes from the config.
For example, `catalog: anthropic` with an Opus 5.5 context override retains its adaptive thinking flags.
Pi-ai computes call costs and stores them in assistant usage. These costs are estimates, not billing totals.
`GET /harness/models` lists configured models and resolved metadata without authentication data.
+4 -2
View File
@@ -51,8 +51,10 @@ The Rust build must not execute npm or download JavaScript packages.
The harness reads configured providers from `/etc/sausage/config.yaml` through Rust.
`models.ts` registers pi-ai adapters for Anthropic Messages, OpenAI Responses, and OpenAI-compatible Chat Completions.
Provider API keys in the config are intentionally visible to agents. Rust redacts them from module errors.
The host refreshes models.dev metadata at startup, with a private cache and a packaged snapshot as offline fallbacks.
Metadata supplies context limits, reasoning support, and prices. Pi-ai stores estimated costs in assistant usage.
Configured models start from pi-ai's public catalog. No separate metadata download, cache, or snapshot is required.
Models retain API compatibility flags, thinking level mappings, prompt caching, input limits, input types, and price tiers.
Config overrides replace only the specified metadata fields. The base URL comes from the config.
Pi-ai stores estimated costs in assistant usage.
Provider and Markdown roster edits apply at the next run boundary.
The console targets the configured main agent conversation. A rename retains its conversation.
Tests use the faux provider or local fake servers without real credentials.
+21857 -21449
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -16
View File
@@ -1,5 +1,6 @@
/** Configured providers with models.dev metadata and pi-ai API adapters. */
/** Configured providers retain pi-ai catalog metadata and API compatibility flags. */
import {createModels, createProvider} from '@earendil-works/pi-ai/models';
import {getBuiltinModel} from '@earendil-works/pi-ai/providers/all';
import {anthropicMessagesApi} from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
import {openAIResponsesApi} from '@earendil-works/pi-ai/api/openai-responses.lazy';
import {openAICompletionsApi} from '@earendil-works/pi-ai/api/openai-completions.lazy';
@@ -10,28 +11,25 @@ const adapters = {'anthropic-messages': anthropicMessagesApi, 'openai-responses'
export function resolveModel(provider: string, settings: any, entry: any, catalog: any) {
const override = typeof entry === 'string' ? {id:entry} : entry;
const id = override.id;
const known = catalog[settings.catalog ?? provider]?.models?.[id];
const metadata = {context_window:known?.limit?.context, max_tokens:known?.limit?.output,
reasoning:known?.reasoning, cost:known?.cost, ...override};
const cost = {...known?.cost, ...override.cost};
const known = catalog(settings.catalog ?? provider,id);
const metadata = {context_window:known?.contextWindow, max_tokens:known?.maxTokens,
reasoning:known?.reasoning, ...override};
const cost = {...known?.cost};
const costFields = {input:'input',output:'output',cache_read:'cacheRead',cache_write:'cacheWrite'};
for (const [key, field] of Object.entries(costFields)) if (override.cost?.[key] !== undefined) cost[field] = override.cost[key];
const missing = ['context_window','max_tokens','reasoning'].filter(key=>metadata[key] === undefined);
for (const key of ['input','output','cache_read','cache_write']) if (cost[key] === undefined) missing.push(`cost.${key}`);
for (const [key, field] of Object.entries(costFields)) if (cost[field] === undefined) missing.push(`cost.${key}`);
if (missing.length) throw new Error(`${CONFIG}: ${provider}/${id} lacks ${missing.join(', ')}`);
const extra = Object.keys(override).filter(key=>!['id','context_window','max_tokens','reasoning','cost'].includes(key));
const extraCost = Object.keys(override.cost ?? {}).filter(key=>!['input','output','cache_read','cache_write'].includes(key));
if (extra.length || extraCost.length) throw new Error(`${CONFIG}: invalid overrides for ${provider}/${id}: ${[...extra,...extraCost].join(', ')}`);
return {id, name:known?.name ?? id, provider, api:settings.type === 'openai-chat-completions' ? 'openai-completions' : settings.type,
return {...known, type:'chat', id, name:known?.name ?? id, provider,
api:settings.type === 'openai-chat-completions' ? 'openai-completions' : settings.type,
baseUrl:settings.base_url, contextWindow:metadata.context_window, maxTokens:metadata.max_tokens,
reasoning:metadata.reasoning, input:known?.modalities?.input?.filter((type:string)=>['text','image'].includes(type)) ?? ['text'],
cost:{input:cost.input, output:cost.output, cacheRead:cost.cache_read, cacheWrite:cost.cache_write}};
reasoning:metadata.reasoning, input:known?.input ?? ['text'], cost};
}
export async function modelCatalog() {
let updated;
try {
const response = await fetch('https://models.dev/api.json',{signal:AbortSignal.timeout(5000)});
if (response.ok) updated = await response.json();
} catch { /* Cached metadata and the packaged snapshot also work offline. */ }
return Deno.core.ops.op_model_catalog(updated ?? null);
export function modelCatalog() {
return getBuiltinModel;
}
export async function configuredModels(readProviders:()=>Promise<any>, catalog:any) {
const models:any = createModels();
File diff suppressed because one or more lines are too long
+19 -9
View File
@@ -1,5 +1,6 @@
/** Real pi-ai API adapters, catalog metadata, live provider edits, and durable usage. */
import {configuredModels,resolveModel,modelCatalog,CONFIG} from './models';
import {getBuiltinModel} from '@earendil-works/pi-ai/providers/all';
import {fs} from './extensions';
import {environment} from './environment';
import {BACKGROUND_CONTEXT as context} from '@earendil-works/chord/context';
@@ -7,19 +8,22 @@ import {Harness,createRegistry} from '@earendil-works/pi-durable';
import {openStorage} from './sqlite';
import modelExtension from '../../extensions/models/server';
const assert=(ok:any,message:string)=>{if(!ok)throw new Error(message)};
const fetch=globalThis.fetch;
globalThis.fetch=async()=>{throw new Error('offline')};
const catalog=await modelCatalog();
assert(catalog.openai.models['gpt-6.1-sol'],'packaged snapshot works offline');
await Deno.core.ops.op_model_catalog({...catalog,cache_fixture:{id:'cache_fixture',models:{}}});
assert((await modelCatalog()).cache_fixture.id==='cache_fixture','persisted metadata cache works offline');
await Deno.core.ops.op_model_catalog(catalog);
globalThis.fetch=fetch;
let config=globalThis.providerSettings;
const readProviders=()=>Deno.core.ops.op_model_providers();
const models=await configuredModels(readProviders,catalog);
const defaults=models.getModel('responses','gpt-6.1-sol');
assert(defaults.contextWindow===catalog.openai.models['gpt-6.1-sol'].limit.context,'catalog context default');
const sol=getBuiltinModel('openai','gpt-6.1-sol');
const opus=models.getModel('messages','claude-opus-5-5');
const opusCatalog=getBuiltinModel('anthropic','claude-opus-5-5');
assert(opus.compat?.forceAdaptiveThinking===true,'Opus 5.5 requires adaptive thinking');
assert(JSON.stringify(opus.thinkingLevelMap)===JSON.stringify(opusCatalog.thinkingLevelMap)&&opus.thinkingLevelMap,'Opus keeps its thinking level map');
assert(defaults.contextWindow===sol.contextWindow,'catalog context default');
assert(sol.cost.tiers?.length&&JSON.stringify(defaults.cost)===JSON.stringify(sol.cost),'Sol keeps its catalog price tiers');
const custom=resolveModel('anthropic-custom',{...config.providers.messages,catalog:'anthropic'},{id:'claude-opus-5-5',context_window:272000},catalog);
assert(custom.contextWindow===272000&&custom.baseUrl===config.providers.messages.base_url,'custom provider overrides its context and URL');
for(const field of ['compat','thinkingLevelMap','promptCache','inputLimits','input','cost'])assert(JSON.stringify(custom[field])===JSON.stringify(opusCatalog[field]),`custom provider retains ${field}`);
assert(custom.compat.forceAdaptiveThinking,'custom provider retains adaptive thinking');
const override=resolveModel('custom',{...config.providers.responses,catalog:'openai'},{id:'gpt-6.1-sol',context_window:272000},catalog);
assert(override.contextWindow===272000&&override.maxTokens===defaults.maxTokens&&override.cost.input===defaults.cost.input,'context-only override preserves catalog values');
let failure='';
@@ -29,7 +33,13 @@ const supplied={id:'unknown-model',context_window:32000,max_tokens:4000,reasonin
assert(resolveModel('custom',config.providers.responses,supplied,catalog).cost.output===2,'unknown model accepts complete overrides');
for(const [provider,id] of [['messages','claude-opus-5-5'],['responses','gpt-6.1-sol'],['chat','gpt-6.1-sol']]){
const model=models.getModel(provider,id);
const result=await models.completeSimple(model,{messages:[{role:'user',content:'Say hello.',timestamp:Date.now()}]},{maxTokens:128});
const result=await models.completeSimple(model,{messages:[{role:'user',content:'Say hello.',timestamp:Date.now()}]},{maxTokens:128,reasoning:provider==='messages'?'high':undefined,onPayload:payload=>{
if(provider==='messages'){
assert(payload.thinking?.type==='adaptive','high thinking uses adaptive, not enabled');
assert(typeof payload.output_config?.effort==='string','adaptive thinking sets output_config.effort');
assert(payload.messages.some(message=>message.output_config?.effort==='high'),'high thinking uses the catalog mid-conversation effort marker');
}
}});
assert(result.stopReason!=='error',`${provider}: ${result.errorMessage}`);
assert(result.content.some(block=>block.type==='text'&&block.text==='hello'),`${provider}: streamed text`);
assert(result.usage.cost.total>0,`${provider}: computed usage cost`);
+1 -28
View File
@@ -1,5 +1,4 @@
//! Fresh reads of provider keys and cached models.dev metadata for the host harness.
use deno_core::OpState;
//! Fresh reads of provider configuration and keys for the host harness.
use sausage_vfs::{Layer, Open};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, sync::Arc};
@@ -33,29 +32,3 @@ impl Config {
.map_err(|error| anyhow::anyhow!("Cannot read {PATH}: {error}"))
}
}
pub(super) struct CatalogCache(pub std::path::PathBuf);
#[deno_core::op2]
#[serde]
pub(super) async fn op_model_catalog(
state: std::rc::Rc<std::cell::RefCell<OpState>>,
#[serde] updated: Option<serde_json::Value>,
) -> Result<serde_json::Value, deno_error::JsErrorBox> {
let path = state.borrow().borrow::<CatalogCache>().0.clone();
let result = async {
if let Some(value) = updated {
tokio::fs::write(&path, serde_json::to_vec(&value)?).await?;
return Ok::<_, anyhow::Error>(value);
}
let bytes = match tokio::fs::read(&path).await {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
include_bytes!("../../js/models-dev.json").to_vec()
}
Err(error) => return Err(error.into()),
};
Ok(serde_json::from_slice(&bytes)?)
}
.await;
result.map_err(|error| deno_error::JsErrorBox::generic(error.to_string()))
}
-5
View File
@@ -85,7 +85,6 @@ deno_core::extension!(
op_sql,
credentials::op_credential_requests,
credentials::op_model_providers,
config::op_model_catalog,
credentials::op_credential_next,
environment::op_file,
environment::op_exec,
@@ -175,10 +174,6 @@ pub async fn run_module_with_input(
})),
..Default::default()
});
runtime
.op_state()
.borrow_mut()
.put(config::CatalogCache(world.host_dir.join("models-dev.json")));
runtime.op_state().borrow_mut().put(loader);
runtime
.op_state()
+3 -2
View File
@@ -104,6 +104,7 @@ async fn provider_transport_and_prices() -> anyhow::Result<()> {
);
let result =
sausage::harness::run_module(World::open(dir.path())?, Arc::new(NoExec), &source).await;
thread.join().unwrap()?;
result
let server_result = thread.join().unwrap();
result?;
server_result
}