Merge branch 'v3' into v3-t105
This commit is contained in:
@@ -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
@@ -185,11 +185,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
-16
@@ -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();
|
||||
|
||||
+21801
-21391
File diff suppressed because one or more lines are too long
@@ -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,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()))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user