diff --git a/app/.monicon/icons.js b/app/.monicon/icons.js index 99972b086..245d76e94 100644 --- a/app/.monicon/icons.js +++ b/app/.monicon/icons.js @@ -1029,5 +1029,20 @@ module.exports = { "svg": "", "width": 16, "height": 16 + }, + "ri:google-fill": { + "svg": "", + "width": "1em", + "height": "1em" + }, + "mdi:image-outline": { + "svg": "", + "width": "1em", + "height": "1em" + }, + "mdi:cloud-off-outline": { + "svg": "", + "width": "1em", + "height": "1em" } }; diff --git a/app/__tests__/__snapshots__/persistSchemaDrift.test.ts.snap.node b/app/__tests__/__snapshots__/persistSchemaDrift.test.ts.snap.node index 4180aec26..de3b44c86 100644 --- a/app/__tests__/__snapshots__/persistSchemaDrift.test.ts.snap.node +++ b/app/__tests__/__snapshots__/persistSchemaDrift.test.ts.snap.node @@ -630,6 +630,1700 @@ exports[`persisted schema drift profile-store schema matches the golden snapshot } `; +exports[`persisted schema drift routstr-store schema matches the golden snapshot for its version: routstr-store 1`] = ` +{ + "name": "routstr-store", + "shape": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "apiKey": { + "anyOf": [ + { + "maxLength": 8192, + "type": "string", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "balance": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "currentSessionId": { + "anyOf": [ + { + "maxLength": 128, + "type": "string", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "lastKnownLineup": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "derivedAt": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "lineup": { + "additionalProperties": false, + "properties": { + "claude": { + "additionalProperties": false, + "properties": { + "auto": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "pro": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "auto", + "pro", + "max", + ], + "type": "object", + }, + "google": { + "additionalProperties": false, + "properties": { + "auto": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "pro": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "auto", + "pro", + "max", + ], + "type": "object", + }, + "grok": { + "additionalProperties": false, + "properties": { + "auto": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "pro": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "auto", + "pro", + "max", + ], + "type": "object", + }, + "openai": { + "additionalProperties": false, + "properties": { + "auto": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "pro": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "contextLength": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "created": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "displayName": { + "maxLength": 256, + "type": "string", + }, + "lastKnown": { + "type": "boolean", + }, + "modelId": { + "maxLength": 256, + "type": "string", + }, + "satsPricing": { + "additionalProperties": false, + "properties": { + "completion": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "image": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "max_cost": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "prompt": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "request": { + "anyOf": [ + { + "type": "number", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "prompt", + "completion", + "request", + "image", + "max_cost", + ], + "type": "object", + }, + "visionInput": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "modelId", + "displayName", + "contextLength", + "created", + "visionInput", + "satsPricing", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + }, + "required": [ + "auto", + "pro", + "max", + ], + "type": "object", + }, + }, + "required": [ + "openai", + "claude", + "grok", + "google", + ], + "type": "object", + }, + }, + "required": [ + "derivedAt", + "lineup", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "selectedModel": { + "anyOf": [ + { + "maxLength": 256, + "type": "string", + }, + { + "type": "null", + }, + ], + "default": null, + }, + "sessions": { + "default": [], + "items": { + "additionalProperties": {}, + "properties": { + "activeChildren": { + "additionalProperties": { + "maxLength": 128, + "type": "string", + }, + "propertyNames": { + "maxLength": 128, + "type": "string", + }, + "type": "object", + }, + "createdAt": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "id": { + "maxLength": 128, + "type": "string", + }, + "messages": { + "items": { + "additionalProperties": {}, + "properties": { + "attachments": { + "default": [], + "items": { + "additionalProperties": false, + "properties": { + "height": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "localUri": { + "maxLength": 2048, + "type": "string", + }, + "mimeType": { + "maxLength": 64, + "type": "string", + }, + "width": { + "default": 0, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "localUri", + "mimeType", + "width", + "height", + ], + "type": "object", + }, + "maxItems": 4, + "type": "array", + }, + "content": { + "maxLength": 65536, + "type": "string", + }, + "costSats": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "id": { + "maxLength": 128, + "type": "string", + }, + "parentId": { + "anyOf": [ + { + "maxLength": 128, + "type": "string", + }, + { + "type": "null", + }, + ], + }, + "pending": { + "type": "boolean", + }, + "reasoningContent": { + "maxLength": 65536, + "type": "string", + }, + "role": { + "enum": [ + "user", + "assistant", + ], + "type": "string", + }, + "thinkingDurationSec": { + "minimum": 0, + "type": "number", + }, + "timestamp": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "id", + "role", + "content", + "timestamp", + ], + "type": "object", + }, + "maxItems": 10000, + "type": "array", + }, + "title": { + "maxLength": 512, + "type": "string", + }, + }, + "required": [ + "id", + "title", + "createdAt", + "messages", + ], + "type": "object", + }, + "maxItems": 1024, + "type": "array", + }, + }, + "required": [ + "apiKey", + "balance", + "selectedModel", + "sessions", + "currentSessionId", + "lastKnownLineup", + ], + "type": "object", + }, + "version": 1, +} +`; + exports[`persisted schema drift transaction-annotation-store schema matches the golden snapshot for its version: transaction-annotation-store 1`] = ` { "name": "transaction-annotation-store", diff --git a/app/__tests__/aiSendAssembly.test.ts b/app/__tests__/aiSendAssembly.test.ts new file mode 100644 index 000000000..0f645656d --- /dev/null +++ b/app/__tests__/aiSendAssembly.test.ts @@ -0,0 +1,207 @@ +/** + * Pins the multimodal request-assembly contract shared by `useAiSend`'s + * send and retry flows (`assembleApiMessages` is the ONE assembly seam, so + * testing it pins both), the routstrStore persistence tolerance for the + * new attachment/lineup fields, and the vision-aware candidate filter's + * building blocks in `format.ts`. + */ +import { + measureMessageContent, + type RoutstrChatMessage, + type RoutstrModel, +} from '@/shared/lib/routstr/api'; +import type { ChatAttachment, RoutstrMessage } from '@/shared/stores/profile/routstrStore'; +import { + MAX_INLINE_IMAGES, + MAX_INLINE_IMAGE_TURNS, + assembleApiMessages, + stripImageParts, +} from '@/features/ai/lib/assembleApiMessages'; +import { + entryForSlot, + estimateTurnCostSatsFromPricing, + resolveCandidateEntries, + resolveSelectedEntry, +} from '@/features/ai/lib/format'; +import { deriveLineup } from '@/shared/lib/routstr/lineup'; +import fixture from './fixtures/routstr-models.fixture.json'; + +const att = (n: number): ChatAttachment => ({ + localUri: `file:///photos/img-${n}.jpg`, + mimeType: 'image/jpeg', + width: 100, + height: 100, +}); + +const msg = ( + id: string, + role: 'user' | 'assistant', + content: string, + attachments?: ChatAttachment[] +): RoutstrMessage => ({ id, role, content, timestamp: 1, attachments }); + +/** Encoder stub: data-URL derived from the URI; records call counts. */ +function makeEncoder(missing: Set = new Set()) { + const calls = new Map(); + const encode = async (attachment: ChatAttachment) => { + calls.set(attachment.localUri, (calls.get(attachment.localUri) ?? 0) + 1); + if (missing.has(attachment.localUri)) return null; + return `data:image/jpeg;base64,${attachment.localUri}`; + }; + return { encode, calls }; +} + +const imageParts = (m: RoutstrChatMessage): number => + typeof m.content === 'string' ? 0 : m.content.filter((p) => p.type === 'image_url').length; + +describe('assembleApiMessages', () => { + it('keeps text-only turns as plain string content (pre-image wire shape unchanged)', async () => { + const { encode } = makeEncoder(); + const { messages, imageCount } = await assembleApiMessages( + [msg('u1', 'user', 'hi'), msg('a1', 'assistant', 'hello'), msg('u2', 'user', 'ok')], + encode + ); + expect(imageCount).toBe(0); + expect(messages).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + { role: 'user', content: 'ok' }, + ]); + }); + + it('expands attachment turns into text + image_url content parts', async () => { + const { encode } = makeEncoder(); + const { messages, imageCount } = await assembleApiMessages( + [msg('u1', 'user', 'what is this?', [att(1), att(2)])], + encode + ); + expect(imageCount).toBe(2); + expect(messages[0].content).toEqual([ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${att(1).localUri}` } }, + { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${att(2).localUri}` } }, + ]); + }); + + it(`inlines images only from the newest ${MAX_INLINE_IMAGE_TURNS} attachment turns; older image turns assemble text-only`, async () => { + const { encode, calls } = makeEncoder(); + const path = [ + msg('u1', 'user', 'first', [att(1)]), + msg('a1', 'assistant', 'r1'), + msg('u2', 'user', 'second', [att(2)]), + msg('a2', 'assistant', 'r2'), + msg('u3', 'user', 'third', [att(3)]), + msg('a3', 'assistant', 'r3'), + ]; + const { messages, imageCount } = await assembleApiMessages(path, encode); + expect(imageCount).toBe(2); + expect(messages[0]).toEqual({ role: 'user', content: 'first' }); // outside the window + expect(imageParts(messages[2])).toBe(1); + expect(imageParts(messages[4])).toBe(1); + // The windowed-out attachment is never even read from disk. + expect(calls.has(att(1).localUri)).toBe(false); + }); + + it(`caps inline images at ${MAX_INLINE_IMAGES} per request, newest turns first`, async () => { + const { encode } = makeEncoder(); + const path = [ + msg('u1', 'user', 'many', [att(1), att(2), att(3)]), + msg('a1', 'assistant', 'r'), + msg('u2', 'user', 'more', [att(4), att(5), att(6)]), + ]; + const { messages, imageCount } = await assembleApiMessages(path, encode); + expect(imageCount).toBe(MAX_INLINE_IMAGES); + expect(imageParts(messages[2])).toBe(3); // newest turn gets its full set + expect(imageParts(messages[0])).toBe(1); // older turn gets the remainder + }); + + it('degrades a missing/expired local URI to text-only with the other images intact', async () => { + const { encode } = makeEncoder(new Set([att(1).localUri])); + const { messages, imageCount } = await assembleApiMessages( + [msg('u1', 'user', 'broken + ok', [att(1), att(2)])], + encode + ); + expect(imageCount).toBe(1); + expect(imageParts(messages[0])).toBe(1); + // The text part survives regardless. + expect((messages[0].content as { type: string }[])[0]).toEqual({ + type: 'text', + text: 'broken + ok', + }); + }); + + it('send/retry parity is structural: identical path in → identical wire messages out', async () => { + const path = [msg('u1', 'user', 'q', [att(1)]), msg('a1', 'assistant', 'r')]; + const a = await assembleApiMessages(path, makeEncoder().encode); + const b = await assembleApiMessages(path, makeEncoder().encode); + expect(a).toEqual(b); + }); + + it('stripImageParts degrades a multimodal request to plain text (no-vision-candidate fallback)', async () => { + const { encode } = makeEncoder(); + const { messages } = await assembleApiMessages( + [msg('u1', 'user', 'look', [att(1), att(2)]), msg('a1', 'assistant', 'reply')], + encode + ); + const stripped = stripImageParts(messages); + expect(stripped).toEqual([ + { role: 'user', content: 'look' }, + { role: 'assistant', content: 'reply' }, + ]); + expect(measureMessageContent(stripped).imageParts).toBe(0); + }); + + it('measureMessageContent counts text chars + image parts without serialising payloads', async () => { + const { encode } = makeEncoder(); + const { messages } = await assembleApiMessages( + [msg('u1', 'user', '12345', [att(1)]), msg('a1', 'assistant', '678')], + encode + ); + expect(measureMessageContent(messages)).toEqual({ textChars: 8, imageParts: 1 }); + }); +}); + +describe('vision-aware candidate resolution (format.ts over the derived lineup)', () => { + const { lineup } = deriveLineup(fixture.data as unknown as RoutstrModel[]); + + it('resolves a (provider, tier) slot and returns null only when no lineup exists', () => { + expect(resolveSelectedEntry('openai', 'auto', 1_000_000, lineup)?.modelId).toBe('gpt-5.5'); + expect(resolveSelectedEntry('openai', 'auto', 1_000_000, null)).toBeNull(); + }); + + it('falls back across providers in the same tier before other tiers', () => { + const chain = resolveCandidateEntries('claude', 'max', lineup); + expect(chain[0]?.modelId).toBe(entryForSlot(lineup, 'claude', 'max')?.modelId); + // Next candidates are the other providers' max cells, in provider order. + expect(chain[1]?.modelId).toBe(entryForSlot(lineup, 'openai', 'max')?.modelId); + expect(chain.length).toBe(12); // every filled cell exactly once + expect(new Set(chain.map((e) => e.modelId)).size).toBe(12); + }); + + it('prefers the first affordable candidate, else the primary at any cost', () => { + // Balance below every max_cost → primary comes back unaffordable-first. + const broke = resolveSelectedEntry('claude', 'max', 0, lineup); + expect(broke?.modelId).toBe(entryForSlot(lineup, 'claude', 'max')?.modelId); + // A balance that can only afford the cheapest cells skips to them. + const cheap = resolveSelectedEntry('openai', 'max', 700, lineup); + expect(cheap).not.toBeNull(); + expect(cheap!.satsPricing.max_cost!).toBeLessThanOrEqual(700); + }); + + it('every selected entry carries visionInput so the send path can filter a chain with images', () => { + const chain = resolveCandidateEntries('google', 'max', lineup); + expect(chain.every((e) => typeof e.visionInput === 'boolean')).toBe(true); + expect(chain.filter((e) => e.visionInput).length).toBeGreaterThan(0); + }); + + it('adds per-image fees into the turn estimate (Gemini undercount fix)', () => { + const gemini = entryForSlot(lineup, 'google', 'max')!; + const base = estimateTurnCostSatsFromPricing(gemini.satsPricing); + const withImages = estimateTurnCostSatsFromPricing(gemini.satsPricing, 2); + expect(base).not.toBeNull(); + expect(withImages!).toBeCloseTo(base! + gemini.satsPricing.image! * 2, 6); + // Models without an image fee are unchanged. + const noFee = estimateTurnCostSatsFromPricing({ ...gemini.satsPricing, image: null }, 2); + expect(noFee).toBeCloseTo(base!, 6); + }); +}); diff --git a/app/__tests__/fixtures/routstr-models.fixture.json b/app/__tests__/fixtures/routstr-models.fixture.json new file mode 100644 index 000000000..286bbc360 --- /dev/null +++ b/app/__tests__/fixtures/routstr-models.fixture.json @@ -0,0 +1,1890 @@ +{ + "data": [ + { + "id": "gpt-chat-latest", + "name": "OpenAI: GPT Chat Latest", + "created": 1778000212, + "description": "GPT Chat Latest points to OpenAI's stable API alias `chat-latest` that always resolves to the latest Instant chat model used in ChatGPT. As OpenAI rolls out new Instant model updates...", + "context_length": 400000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 3.7875000000000006e-06, + "completion": 2.2725e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 3.7875000000000003e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 1.5150000000000001, + "max_completion_cost": 2.9088, + "max_cost": 3.939 + }, + "sats_pricing": { + "prompt": 0.00618738135236798, + "completion": 0.03712428811420787, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.000618738135236798, + "input_cache_write": 0.0, + "max_prompt_cost": 2474.9525409471917, + "max_completion_cost": 4751.908878618608, + "max_cost": 6434.876606462698 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 400000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-chat-latest-20260505", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-mini-latest", + "name": "OpenAI GPT Mini Latest", + "created": 1777318471, + "description": "This model always redirects to the latest model in the OpenAI GPT Mini family.", + "context_length": 400000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": 5.681250000000001e-07, + "completion": 3.4087499999999998e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 5.68125e-08, + "input_cache_write": 0.0, + "max_prompt_cost": 0.22725000000000004, + "max_completion_cost": 0.43632, + "max_cost": 0.59085 + }, + "sats_pricing": { + "prompt": 0.0009281072028551969, + "completion": 0.005568643217131181, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 9.281072028551968e-05, + "input_cache_write": 0.0, + "max_prompt_cost": 371.2428811420788, + "max_completion_cost": 712.7863317927912, + "max_cost": 965.2314909694047 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 400000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~openai/gpt-mini-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-latest", + "name": "OpenAI GPT Latest", + "created": 1777318334, + "description": "This model always redirects to the latest model in the OpenAI GPT family.", + "context_length": 1050000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": 3.7875000000000006e-06, + "completion": 2.2725e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 3.7875000000000003e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 3.9768750000000006, + "max_completion_cost": 2.9088, + "max_cost": 6.400875000000001 + }, + "sats_pricing": { + "prompt": 0.00618738135236798, + "completion": 0.03712428811420787, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.000618738135236798, + "input_cache_write": 0.0, + "max_prompt_cost": 6496.750419986379, + "max_completion_cost": 4751.908878618608, + "max_cost": 10456.674485501886 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1050000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~openai/gpt-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-5.5-pro", + "name": "OpenAI: GPT-5.5 Pro", + "created": 1777051896, + "description": "GPT-5.5 Pro is OpenAI\u2019s high-capability model optimized for deep reasoning and accuracy on complex, high-stakes workloads. It features a 1M+ token context window (922K input, 128K output) with support for...", + "context_length": 1050000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 2.2725e-05, + "completion": 0.00013635, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 2.2725000000000003e-06, + "input_cache_write": 0.0, + "max_prompt_cost": 23.861250000000002, + "max_completion_cost": 17.4528, + "max_cost": 38.405249999999995 + }, + "sats_pricing": { + "prompt": 0.03712428811420787, + "completion": 0.22274572868524725, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.0037124288114207876, + "input_cache_write": 0.0, + "max_prompt_cost": 38980.50251991827, + "max_completion_cost": 28511.453271711645, + "max_cost": 62740.0469130113 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1050000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-5.5-pro-20260423", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-5.5", + "name": "OpenAI: GPT-5.5", + "created": 1777051893, + "description": "GPT-5.5 is OpenAI\u2019s frontier model designed for complex professional workloads, building on GPT-5.4 with stronger reasoning, higher reliability, and improved token efficiency on hard tasks. It features a 1M+ token...", + "context_length": 1050000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 3.7875000000000006e-06, + "completion": 2.2725e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 3.7875000000000003e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 3.9768750000000006, + "max_completion_cost": 2.9088, + "max_cost": 6.400875000000001 + }, + "sats_pricing": { + "prompt": 0.00618738135236798, + "completion": 0.03712428811420787, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.000618738135236798, + "input_cache_write": 0.0, + "max_prompt_cost": 6496.750419986379, + "max_completion_cost": 4751.908878618608, + "max_cost": 10456.674485501886 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1050000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-5.5-20260423", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-5.4-image-2", + "name": "OpenAI: GPT-5.4 Image 2", + "created": 1776797528, + "description": "[GPT-5.4](https://openrouter.ai/openai/gpt-5.4) Image 2 combines OpenAI's GPT-5.4 model with state-of-the-art image generation capabilities from GPT Image 2. It enables rich multimodal workflows, allowing users to seamlessly move between reasoning, coding, and...", + "context_length": 272000, + "architecture": { + "modality": "text+image+file->text+image", + "input_modalities": [ + "image", + "text", + "file" + ], + "output_modalities": [ + "image", + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 6.0600000000000004e-06, + "completion": 1.13625e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 1.5150000000000001e-06, + "input_cache_write": 0.0, + "max_prompt_cost": 1.64832, + "max_completion_cost": 1.4544, + "max_cost": 2.32704 + }, + "sats_pricing": { + "prompt": 0.009899810163788767, + "completion": 0.018562144057103935, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.002474952540947192, + "input_cache_write": 0.0, + "max_prompt_cost": 2692.7483645505445, + "max_completion_cost": 2375.954439309304, + "max_cost": 3801.5271028948864 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 272000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-5.4-image-2-20260421", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-5.4-nano", + "name": "OpenAI: GPT-5.4 Nano", + "created": 1773748187, + "description": "GPT-5.4 nano is the most lightweight and cost-efficient variant of the GPT-5.4 family, optimized for speed-critical and high-volume tasks. It supports text and image inputs and is designed for low-latency...", + "context_length": 400000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 1.515e-07, + "completion": 9.468750000000001e-07, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 1.5150000000000003e-08, + "input_cache_write": 0.0, + "max_prompt_cost": 0.0606, + "max_completion_cost": 0.12120000000000002, + "max_cost": 0.16240800000000002 + }, + "sats_pricing": { + "prompt": 0.00024749525409471914, + "completion": 0.001546845338091995, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 2.474952540947192e-05, + "input_cache_write": 0.0, + "max_prompt_cost": 98.99810163788767, + "max_completion_cost": 197.99620327577534, + "max_cost": 265.31491238953896 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 400000, + "max_completion_tokens": 128000, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-5.4-nano-20260317", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-5.4-mini", + "name": "OpenAI: GPT-5.4 Mini", + "created": 1773748178, + "description": "GPT-5.4 mini brings the core capabilities of GPT-5.4 to a faster, more efficient model optimized for high-throughput workloads. It supports text and image inputs with strong performance across reasoning, coding,...", + "context_length": 400000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 5.681250000000001e-07, + "completion": 3.4087499999999998e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 5.68125e-08, + "input_cache_write": 0.0, + "max_prompt_cost": 0.22725000000000004, + "max_completion_cost": 0.43632, + "max_cost": 0.59085 + }, + "sats_pricing": { + "prompt": 0.0009281072028551969, + "completion": 0.005568643217131181, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 9.281072028551968e-05, + "input_cache_write": 0.0, + "max_prompt_cost": 371.2428811420788, + "max_completion_cost": 712.7863317927912, + "max_cost": 965.2314909694047 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 400000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-5.4-mini-20260317", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-audio", + "name": "OpenAI: GPT Audio", + "created": 1768862569, + "description": "The gpt-audio model is OpenAI's first generally available audio model. The new snapshot features an upgraded decoder for more natural sounding voices and maintains better voice consistency. Audio is priced...", + "context_length": 128000, + "architecture": { + "modality": "text+audio->text+audio", + "input_modalities": [ + "text", + "audio" + ], + "output_modalities": [ + "text", + "audio" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 1.8937500000000003e-06, + "completion": 7.575000000000001e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 0.24240000000000003, + "max_completion_cost": 0.12410880000000002, + "max_cost": 0.33548160000000005 + }, + "sats_pricing": { + "prompt": 0.00309369067618399, + "completion": 0.01237476270473596, + "request": 0.001, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 395.9924065515507, + "max_completion_cost": 202.74811215439397, + "max_cost": 548.0534906673462 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 128000, + "max_completion_tokens": 16384, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-audio", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-sonnet-5", + "name": "Anthropic: Claude Sonnet 5", + "created": 1782843083, + "description": "Sonnet 5 is Anthropic's most capable Sonnet-class model, with frontier performance across coding, agents, and professional work. It supports adaptive thinking with selectable reasoning effort levels (low, medium, high, max,...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": 1.5150000000000001e-06, + "completion": 7.575000000000001e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 1.515e-07, + "input_cache_write": 1.8937500000000003e-06, + "max_prompt_cost": 1.5150000000000001, + "max_completion_cost": 0.9696000000000001, + "max_cost": 2.29068 + }, + "sats_pricing": { + "prompt": 0.002474952540947192, + "completion": 0.01237476270473596, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.00309369067618399, + "max_prompt_cost": 2474.9525409471917, + "max_completion_cost": 1583.9696262062027, + "max_cost": 3742.128241912154 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "anthropic/claude-sonnet-5-20260630", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-fable-latest", + "name": "Anthropic: Claude Fable Latest", + "created": 1781029944, + "description": "This model always redirects to the latest model in the Claude Fable family.", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": 7.575000000000001e-06, + "completion": 3.7875000000000006e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 7.575000000000001e-07, + "input_cache_write": 9.468750000000001e-06, + "max_prompt_cost": 7.575000000000001, + "max_completion_cost": 4.848000000000001, + "max_cost": 11.453400000000002 + }, + "sats_pricing": { + "prompt": 0.01237476270473596, + "completion": 0.0618738135236798, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.001237476270473596, + "input_cache_write": 0.01546845338091995, + "max_prompt_cost": 12374.76270473596, + "max_completion_cost": 7919.848131031014, + "max_cost": 18710.64120956077 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~anthropic/claude-fable-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-fable-5", + "name": "Anthropic: Claude Fable 5", + "created": 1781007515, + "description": "Claude Fable 5 is a Mythos-class model from Anthropic, built for autonomous knowledge work and coding. It supports text, image, and file inputs with text output, with reasoning support and...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": 7.575000000000001e-06, + "completion": 3.7875000000000006e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 7.575000000000001e-07, + "input_cache_write": 9.468750000000001e-06, + "max_prompt_cost": 7.575000000000001, + "max_completion_cost": 4.848000000000001, + "max_cost": 11.453400000000002 + }, + "sats_pricing": { + "prompt": 0.01237476270473596, + "completion": 0.0618738135236798, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.001237476270473596, + "input_cache_write": 0.01546845338091995, + "max_prompt_cost": 12374.76270473596, + "max_completion_cost": 7919.848131031014, + "max_cost": 18710.64120956077 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "anthropic/claude-5-fable-20260609", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-opus-4.8-fast", + "name": "Anthropic: Claude Opus 4.8 (Fast)", + "created": 1779913703, + "description": "Fast-mode variant of [Opus 4.8](/anthropic/claude-opus-4.8) - identical capabilities with higher output speed at 2x pricing relative to regular Opus 4.8.\n\nLearn more in Anthropic's docs: https://platform.claude.com/docs/en/build-with-claude/fast-mode", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": 7.575000000000001e-06, + "completion": 3.7875000000000006e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 7.575000000000001e-07, + "input_cache_write": 9.468750000000001e-06, + "max_prompt_cost": 7.575000000000001, + "max_completion_cost": 4.848000000000001, + "max_cost": 11.453400000000002 + }, + "sats_pricing": { + "prompt": 0.01237476270473596, + "completion": 0.0618738135236798, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.001237476270473596, + "input_cache_write": 0.01546845338091995, + "max_prompt_cost": 12374.76270473596, + "max_completion_cost": 7919.848131031014, + "max_cost": 18710.64120956077 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "anthropic/claude-4.8-opus-fast-20260528", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-opus-4.8", + "name": "Anthropic: Claude Opus 4.8", + "created": 1779905091, + "description": "Claude Opus 4.8 is Anthropic's most capable generally available model in the Opus family. It supports text, image, and file inputs with text output, with reasoning support and a 1M-token...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": 3.7875000000000006e-06, + "completion": 1.8937500000000003e-05, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 3.7875000000000003e-07, + "input_cache_write": 4.734375000000001e-06, + "max_prompt_cost": 3.7875000000000005, + "max_completion_cost": 2.4240000000000004, + "max_cost": 5.726700000000001 + }, + "sats_pricing": { + "prompt": 0.00618738135236798, + "completion": 0.0309369067618399, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.000618738135236798, + "input_cache_write": 0.007734226690459975, + "max_prompt_cost": 6187.38135236798, + "max_completion_cost": 3959.924065515507, + "max_cost": 9355.320604780385 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "anthropic/claude-4.8-opus-20260528", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-haiku-latest", + "name": "Anthropic Claude Haiku Latest", + "created": 1777318492, + "description": "This model always redirects to the latest model in the Anthropic Claude Haiku family.", + "context_length": 200000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": 7.575000000000001e-07, + "completion": 3.7875000000000006e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 7.575e-08, + "input_cache_write": 9.468750000000001e-07, + "max_prompt_cost": 0.15150000000000002, + "max_completion_cost": 0.24240000000000003, + "max_cost": 0.34542000000000006 + }, + "sats_pricing": { + "prompt": 0.001237476270473596, + "completion": 0.00618738135236798, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.00012374762704735957, + "input_cache_write": 0.001546845338091995, + "max_prompt_cost": 247.4952540947192, + "max_completion_cost": 395.9924065515507, + "max_cost": 564.2891793359598 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 200000, + "max_completion_tokens": 64000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~anthropic/claude-haiku-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "claude-haiku-4.5", + "name": "Anthropic: Claude Haiku 4.5", + "created": 1760547638, + "description": "Claude Haiku 4.5 is Anthropic\u2019s fastest and most efficient model, delivering near-frontier intelligence at a fraction of the cost and latency of larger Claude models. Matching Claude Sonnet 4\u2019s performance...", + "context_length": 200000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": 7.575000000000001e-07, + "completion": 3.7875000000000006e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 7.575e-08, + "input_cache_write": 9.468750000000001e-07, + "max_prompt_cost": 0.15150000000000002, + "max_completion_cost": 0.24240000000000003, + "max_cost": 0.34542000000000006 + }, + "sats_pricing": { + "prompt": 0.001237476270473596, + "completion": 0.00618738135236798, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 0.00012374762704735957, + "input_cache_write": 0.001546845338091995, + "max_prompt_cost": 247.4952540947192, + "max_completion_cost": 395.9924065515507, + "max_cost": 564.2891793359598 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 200000, + "max_completion_tokens": 64000, + "is_moderated": true + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "anthropic/claude-4.5-haiku-20251001", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "grok-build-0.1", + "name": "xAI: Grok Build 0.1", + "created": 1779298123, + "description": "Grok Build 0.1 is xAI\u2019s fast coding model trained specifically for agentic software engineering workflows. It supports text and image inputs with text output, and is optimized for interactive coding...", + "context_length": 256000, + "architecture": { + "modality": "text+image->text", + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": 7.575000000000001e-07, + "completion": 1.5150000000000001e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0037875, + "internal_reasoning": 0.0, + "input_cache_read": 1.515e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 0.19392, + "max_completion_cost": 0.38784, + "max_cost": 0.38784 + }, + "sats_pricing": { + "prompt": 0.001237476270473596, + "completion": 0.002474952540947192, + "request": 0.001, + "image": 0.0, + "web_search": 6.1873813523679795, + "internal_reasoning": 0.0, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.0, + "max_prompt_cost": 316.7939252412405, + "max_completion_cost": 633.587850482481, + "max_cost": 633.587850482481 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 256000, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "x-ai/grok-build-0.1-20260520", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "grok-4.3", + "name": "xAI: Grok 4.3", + "created": 1777591821, + "description": "Grok 4.3 is a reasoning model from xAI. It accepts text and image inputs with text output, and is suited for agentic workflows, instruction-following tasks, and applications requiring high factual...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": 9.468750000000001e-07, + "completion": 1.8937500000000003e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0037875, + "internal_reasoning": 0.0, + "input_cache_read": 1.515e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 0.9468750000000001, + "max_completion_cost": 1.8937500000000003, + "max_cost": 1.8937500000000003 + }, + "sats_pricing": { + "prompt": 0.001546845338091995, + "completion": 0.00309369067618399, + "request": 0.001, + "image": 0.0, + "web_search": 6.1873813523679795, + "internal_reasoning": 0.0, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.0, + "max_prompt_cost": 1546.845338091995, + "max_completion_cost": 3093.69067618399, + "max_cost": 3093.69067618399 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "x-ai/grok-4.3-20260430", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "grok-4.20-multi-agent", + "name": "xAI: Grok 4.20 Multi-Agent", + "created": 1774979158, + "description": "Grok 4.20 Multi-Agent is a variant of xAI\u2019s Grok 4.20 designed for collaborative, agent-based workflows. Multiple agents operate in parallel to conduct deep research, coordinate tool use, and synthesize information...", + "context_length": 2000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": 9.468750000000001e-07, + "completion": 1.8937500000000003e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0037875, + "internal_reasoning": 0.0, + "input_cache_read": 1.515e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 1.8937500000000003, + "max_completion_cost": 3.7875000000000005, + "max_cost": 3.7875000000000005 + }, + "sats_pricing": { + "prompt": 0.001546845338091995, + "completion": 0.00309369067618399, + "request": 0.001, + "image": 0.0, + "web_search": 6.1873813523679795, + "internal_reasoning": 0.0, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.0, + "max_prompt_cost": 3093.69067618399, + "max_completion_cost": 6187.38135236798, + "max_cost": 6187.38135236798 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 2000000, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "x-ai/grok-4.20-multi-agent-20260309", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "grok-4.20", + "name": "xAI: Grok 4.20", + "created": 1774979019, + "description": "Grok 4.20 is a reasoning model from xAI with industry-leading speed and agentic tool calling capabilities. It combines the lowest hallucination rate on the market with strict prompt adherance, delivering...", + "context_length": 2000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": 9.468750000000001e-07, + "completion": 1.8937500000000003e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0037875, + "internal_reasoning": 0.0, + "input_cache_read": 1.515e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 1.8937500000000003, + "max_completion_cost": 3.7875000000000005, + "max_cost": 3.7875000000000005 + }, + "sats_pricing": { + "prompt": 0.001546845338091995, + "completion": 0.00309369067618399, + "request": 0.001, + "image": 0.0, + "web_search": 6.1873813523679795, + "internal_reasoning": 0.0, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.0, + "max_prompt_cost": 3093.69067618399, + "max_completion_cost": 6187.38135236798, + "max_cost": 6187.38135236798 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 2000000, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "x-ai/grok-4.20-20260309", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemini-3.5-flash", + "name": "Google: Gemini 3.5 Flash", + "created": 1779193800, + "description": "Gemini 3.5 Flash is Google's high-efficiency multimodal model, bringing near-Pro level coding and reasoning at Flash-tier cost and speed. It is highly optimized for coding proficiency and parallel agentic execution...", + "context_length": 1048576, + "architecture": { + "modality": "text+image+file+audio+video->text", + "input_modalities": [ + "text", + "image", + "video", + "file", + "audio" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Gemini", + "instruct_type": null + }, + "pricing": { + "prompt": 1.1362500000000001e-06, + "completion": 6.8174999999999995e-06, + "request": 0.0, + "image": 1.1362500000000001e-06, + "web_search": 0.010605000000000002, + "internal_reasoning": 6.8174999999999995e-06, + "input_cache_read": 1.13625e-07, + "input_cache_write": 6.312500000000002e-08, + "max_prompt_cost": 1.1914444800000001, + "max_completion_cost": 0.44679167999999997, + "max_cost": 1.56377088 + }, + "sats_pricing": { + "prompt": 0.0018562144057103938, + "completion": 0.011137286434262362, + "request": 0.001, + "image": 0.0018562144057103938, + "web_search": 17.324667786630343, + "internal_reasoning": 0.011137286434262362, + "input_cache_read": 0.00018562144057103936, + "input_cache_write": 0.00010312302253946634, + "max_prompt_cost": 1946.3818766821819, + "max_completion_cost": 729.8932037558181, + "max_cost": 2554.6262131453636 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1048576, + "max_completion_tokens": 65536, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "google/gemini-3.5-flash-20260519", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemini-3.1-flash-lite", + "name": "Google: Gemini 3.1 Flash Lite", + "created": 1778168828, + "description": "Gemini 3.1 Flash Lite is Google\u2019s GA high-efficiency multimodal model optimized for low-latency, high-volume workloads. It supports text, image, video, audio, and PDF inputs, and is designed for lightweight agentic...", + "context_length": 1048576, + "architecture": { + "modality": "text+image+file+audio+video->text", + "input_modalities": [ + "text", + "image", + "video", + "file", + "audio" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Gemini", + "instruct_type": null + }, + "pricing": { + "prompt": 1.8937500000000001e-07, + "completion": 1.1362500000000001e-06, + "request": 0.0, + "image": 1.8937500000000001e-07, + "web_search": 0.010605000000000002, + "internal_reasoning": 1.1362500000000001e-06, + "input_cache_read": 1.89375e-08, + "input_cache_write": 6.312500000000002e-08, + "max_prompt_cost": 0.19857408000000001, + "max_completion_cost": 0.07446528000000001, + "max_cost": 0.26062848 + }, + "sats_pricing": { + "prompt": 0.000309369067618399, + "completion": 0.0018562144057103938, + "request": 0.001, + "image": 0.000309369067618399, + "web_search": 17.324667786630343, + "internal_reasoning": 0.0018562144057103938, + "input_cache_read": 3.093690676183989e-05, + "input_cache_write": 0.00010312302253946634, + "max_prompt_cost": 324.39697944703033, + "max_completion_cost": 121.64886729263637, + "max_cost": 425.7710355242273 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1048576, + "max_completion_tokens": 65536, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "google/gemini-3.1-flash-lite-20260507", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemini-pro-latest", + "name": "Google Gemini Pro Latest", + "created": 1777318451, + "description": "This model always redirects to the latest model in the Google Gemini Pro family.", + "context_length": 1048576, + "architecture": { + "modality": "text+image+file+audio+video->text", + "input_modalities": [ + "audio", + "file", + "image", + "text", + "video" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": 1.5150000000000001e-06, + "completion": 9.090000000000001e-06, + "request": 0.0, + "image": 1.5150000000000001e-06, + "web_search": 0.010605000000000002, + "internal_reasoning": 9.090000000000001e-06, + "input_cache_read": 1.515e-07, + "input_cache_write": 2.8406250000000003e-07, + "max_prompt_cost": 1.5885926400000001, + "max_completion_cost": 0.5957222400000001, + "max_cost": 2.08502784 + }, + "sats_pricing": { + "prompt": 0.002474952540947192, + "completion": 0.01484971524568315, + "request": 0.001, + "image": 0.002474952540947192, + "web_search": 17.324667786630343, + "internal_reasoning": 0.01484971524568315, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.00046405360142759845, + "max_prompt_cost": 2595.1758355762427, + "max_completion_cost": 973.1909383410909, + "max_cost": 3406.168284193818 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1048576, + "max_completion_tokens": 65536, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~google/gemini-pro-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemini-flash-latest", + "name": "Google Gemini Flash Latest", + "created": 1777318398, + "description": "This model always redirects to the latest model in the Google Gemini Flash family.", + "context_length": 1048576, + "architecture": { + "modality": "text+image+file+audio+video->text", + "input_modalities": [ + "text", + "image", + "video", + "file", + "audio" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": 1.1362500000000001e-06, + "completion": 6.8174999999999995e-06, + "request": 0.0, + "image": 1.1362500000000001e-06, + "web_search": 0.010605000000000002, + "internal_reasoning": 6.8174999999999995e-06, + "input_cache_read": 1.13625e-07, + "input_cache_write": 6.312500000000002e-08, + "max_prompt_cost": 1.1914444800000001, + "max_completion_cost": 0.44679167999999997, + "max_cost": 1.56377088 + }, + "sats_pricing": { + "prompt": 0.0018562144057103938, + "completion": 0.011137286434262362, + "request": 0.001, + "image": 0.0018562144057103938, + "web_search": 17.324667786630343, + "internal_reasoning": 0.011137286434262362, + "input_cache_read": 0.00018562144057103936, + "input_cache_write": 0.00010312302253946634, + "max_prompt_cost": 1946.3818766821819, + "max_completion_cost": 729.8932037558181, + "max_cost": 2554.6262131453636 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1048576, + "max_completion_tokens": 65536, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~google/gemini-flash-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B ", + "created": 1775227989, + "description": "Gemma 4 26B A4B IT is an instruction-tuned Mixture-of-Experts (MoE) model from Google DeepMind. Despite 25.2B total parameters, only 3.8B activate per token during inference \u2014 delivering near-31B quality at...", + "context_length": 262144, + "architecture": { + "modality": "text+image+video->text", + "input_modalities": [ + "image", + "text", + "video" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Gemma", + "instruct_type": null + }, + "pricing": { + "prompt": 4.5449999999999993e-08, + "completion": 2.4997500000000004e-07, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 0.011914444799999998, + "max_completion_cost": 0.06552944640000001, + "max_cost": 0.06552944640000001 + }, + "sats_pricing": { + "prompt": 7.424857622841574e-05, + "completion": 0.0004083671692562867, + "request": 0.001, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 19.463818766821817, + "max_completion_cost": 107.05100321752002, + "max_cost": 107.05100321752002 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 262144, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "google/gemma-4-26b-a4b-it-20260403", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemini-3-pro-image", + "name": "Google: Nano Banana Pro (Gemini 3 Pro Image)", + "created": 1781754054, + "description": "Nano Banana Pro is Google\u2019s most advanced image-generation and editing model, built on Gemini 3 Pro. It extends the original Nano Banana with significantly improved multimodal reasoning, real-world grounding, and...", + "context_length": 65536, + "architecture": { + "modality": "text+image->text+image", + "input_modalities": [ + "image", + "text" + ], + "output_modalities": [ + "image", + "text" + ], + "tokenizer": "Gemini", + "instruct_type": null + }, + "pricing": { + "prompt": 1.5150000000000001e-06, + "completion": 9.090000000000001e-06, + "request": 0.0, + "image": 1.5150000000000001e-06, + "web_search": 0.010605000000000002, + "internal_reasoning": 9.090000000000001e-06, + "input_cache_read": 1.515e-07, + "input_cache_write": 2.8406250000000003e-07, + "max_prompt_cost": 0.09928704000000001, + "max_completion_cost": 0.29786112000000003, + "max_cost": 0.34750464000000003 + }, + "sats_pricing": { + "prompt": 0.002474952540947192, + "completion": 0.01484971524568315, + "request": 0.001, + "image": 0.002474952540947192, + "web_search": 17.324667786630343, + "internal_reasoning": 0.01484971524568315, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.00046405360142759845, + "max_prompt_cost": 162.19848972351517, + "max_completion_cost": 486.59546917054547, + "max_cost": 567.6947140323031 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 65536, + "max_completion_tokens": 32768, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "google/gemini-3-pro-image-20260528", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemini-embedding-2", + "name": "Google: Gemini Embedding 2", + "created": 1779290135, + "description": "Gemini Embedding 2 is Google's first multimodal embedding model. We currently support mapping text and images into a unified vector space for semantic search and retrieval-augmented generation (RAG). It supports...", + "context_length": 8192, + "architecture": { + "modality": "text+image+file+audio+video->embeddings", + "input_modalities": [ + "text", + "image", + "file", + "audio", + "video" + ], + "output_modalities": [ + "embeddings" + ], + "tokenizer": "Gemini", + "instruct_type": null + }, + "pricing": { + "prompt": 1.515e-07, + "completion": 0.0, + "request": 0.0, + "image": 3.40875e-07, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 0.001241088, + "max_completion_cost": 0.0, + "max_cost": 0.001241088 + }, + "sats_pricing": { + "prompt": 0.00024749525409471914, + "completion": 0.0, + "request": 0.001, + "image": 0.0005568643217131181, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 2.0274811215439392, + "max_completion_cost": 0.0, + "max_cost": 2.0274811215439392 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 8192, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "google/gemini-embedding-2", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gemma4:31b", + "name": "gemma4 31b", + "created": 1781126043, + "description": "Ollama gemma4 model (31.3B)", + "context_length": 2048, + "architecture": { + "modality": "text", + "input_modalities": [ + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "gguf", + "instruct_type": null + }, + "pricing": { + "prompt": 6.06e-07, + "completion": 6.06e-07, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 0.001241088, + "max_completion_cost": 0.000620544, + "max_cost": 0.001241088 + }, + "sats_pricing": { + "prompt": 0.0009899810163788766, + "completion": 0.0009899810163788766, + "request": 0.001, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 2.0274811215439392, + "max_completion_cost": 1.0137405607719696, + "max_cost": 2.0274811215439392 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 2048, + "max_completion_tokens": 1024, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": null, + "alias_ids": null, + "forwarded_model_id": "gemma4:31b" + }, + { + "id": "deepseek-v4-pro", + "name": "deepseek-v4-pro", + "created": 1781822958, + "description": "deepseek-v4-pro via deepseek", + "context_length": 1048576, + "architecture": { + "modality": "text", + "input_modalities": [ + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "unknown", + "instruct_type": null + }, + "pricing": { + "prompt": 4.343e-07, + "completion": 8.787000000000001e-07, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 3.6612500000000004e-09, + "input_cache_write": 0.0, + "max_prompt_cost": 0.4553965568, + "max_completion_cost": 0.3374208, + "max_cost": 0.6260461568 + }, + "sats_pricing": { + "prompt": 0.0007094863950715283, + "completion": 0.0014354724737493712, + "request": 0.001, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 5.981135307289047e-06, + "input_cache_write": 0.0, + "max_prompt_cost": 743.9504061985228, + "max_completion_cost": 551.2214299197585, + "max_cost": 1022.7290604108144 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1048576, + "max_completion_tokens": 384000, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": null, + "alias_ids": null, + "forwarded_model_id": "deepseek-v4-pro" + }, + { + "id": "qwen3.7-plus", + "name": "Qwen: Qwen3.7 Plus", + "created": 1780491783, + "description": "Qwen3.7-Plus is a cost-effective model in Alibaba's Qwen3.7 series. It supports text and image input with text output, building on the series' text capabilities with a comprehensive upgrade to its...", + "context_length": 1000000, + "architecture": { + "modality": "text+image->text", + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Qwen", + "instruct_type": null + }, + "pricing": { + "prompt": 2.4240000000000005e-07, + "completion": 9.696000000000002e-07, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 4.848000000000001e-08, + "input_cache_write": 3.03e-07, + "max_prompt_cost": 0.24240000000000006, + "max_completion_cost": 0.06354370560000001, + "max_cost": 0.2900577792000001 + }, + "sats_pricing": { + "prompt": 0.00039599240655155074, + "completion": 0.001583969626206203, + "request": 0.001, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 7.919848131031014e-05, + "input_cache_write": 0.0004949905081894383, + "max_prompt_cost": 395.99240655155074, + "max_completion_cost": 103.80703342304972, + "max_cost": 473.84768161883807 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 65536, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "qwen/qwen3.7-plus-20260602", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "mistral-medium-3-5", + "name": "Mistral: Mistral Medium 3.5", + "created": 1777570439, + "description": "Mistral Medium 3.5 is a dense 128B instruction-following model from Mistral AI. It supports text and image inputs with text output, and is designed for agentic workflows, coding, and complex...", + "context_length": 262144, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Mistral", + "instruct_type": null + }, + "pricing": { + "prompt": 1.1362500000000001e-06, + "completion": 5.68125e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 0.29786112000000003, + "max_completion_cost": 1.4893056, + "max_cost": 1.4893056 + }, + "sats_pricing": { + "prompt": 0.0018562144057103938, + "completion": 0.009281072028551968, + "request": 0.001, + "image": 0.0, + "web_search": 0.0, + "internal_reasoning": 0.0, + "input_cache_read": 0.0, + "input_cache_write": 0.0, + "max_prompt_cost": 486.59546917054547, + "max_completion_cost": 2432.977345852727, + "max_cost": 2432.977345852727 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 262144, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "mistralai/mistral-medium-3.5-20260430", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "grok-latest", + "name": "xAI: Grok Latest", + "created": 1777591821, + "description": "Rolling alias: always redirects to the latest model in the Grok family (synthetic fixture row).", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": 9.468750000000001e-07, + "completion": 1.8937500000000003e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.0037875, + "internal_reasoning": 0.0, + "input_cache_read": 1.515e-07, + "input_cache_write": 0.0, + "max_prompt_cost": 0.9468750000000001, + "max_completion_cost": 1.8937500000000003, + "max_cost": 1.8937500000000003 + }, + "sats_pricing": { + "prompt": 0.001546845338091995, + "completion": 0.00309369067618399, + "request": 0.001, + "image": 0.0, + "web_search": 6.1873813523679795, + "internal_reasoning": 0.0, + "input_cache_read": 0.00024749525409471914, + "input_cache_write": 0.0, + "max_prompt_cost": 1546.845338091995, + "max_completion_cost": 3093.69067618399, + "max_cost": 3093.69067618399 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": null, + "is_moderated": false + }, + "enabled": true, + "upstream_provider_id": "routstr", + "canonical_slug": "~x-ai/grok-latest", + "alias_ids": null, + "forwarded_model_id": null + }, + { + "id": "gpt-5.4-mini-disabled", + "name": "OpenAI: GPT-5.4 Mini (disabled)", + "created": 1773748178, + "description": "GPT-5.4 mini brings the core capabilities of GPT-5.4 to a faster, more efficient model optimized for high-throughput workloads. It supports text and image inputs with strong performance across reasoning, coding,...", + "context_length": 400000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": 5.681250000000001e-07, + "completion": 3.4087499999999998e-06, + "request": 0.0, + "image": 0.0, + "web_search": 0.007575, + "internal_reasoning": 0.0, + "input_cache_read": 5.68125e-08, + "input_cache_write": 0.0, + "max_prompt_cost": 0.22725000000000004, + "max_completion_cost": 0.43632, + "max_cost": 0.59085 + }, + "sats_pricing": { + "prompt": 0.0009281072028551969, + "completion": 0.005568643217131181, + "request": 0.001, + "image": 0.0, + "web_search": 12.374762704735959, + "internal_reasoning": 0.0, + "input_cache_read": 9.281072028551968e-05, + "input_cache_write": 0.0, + "max_prompt_cost": 371.2428811420788, + "max_completion_cost": 712.7863317927912, + "max_cost": 965.2314909694047 + }, + "per_request_limits": null, + "top_provider": { + "context_length": 400000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "enabled": false, + "upstream_provider_id": "routstr", + "canonical_slug": "openai/gpt-5.4-mini-disabled", + "alias_ids": null, + "forwarded_model_id": null + } + ] +} \ No newline at end of file diff --git a/app/__tests__/persistSchemaDrift.test.ts b/app/__tests__/persistSchemaDrift.test.ts index e1e69eead..93fef0ee9 100644 --- a/app/__tests__/persistSchemaDrift.test.ts +++ b/app/__tests__/persistSchemaDrift.test.ts @@ -25,12 +25,15 @@ import '@/shared/stores/profile/nostrSocialStore'; import '@/shared/stores/profile/dataMigrationStore'; import '@/shared/stores/global/mintMetadataStore'; import '@/shared/stores/profile/mintStore'; +// AI chat: apiKey + sessions (now incl. message attachments) + the +// last-known model lineup — none cheaply refetchable. +import '@/shared/stores/profile/routstrStore'; import { persistRegistry } from '@/shared/lib/persist/persistConfig'; describe('persisted schema drift', () => { it('registered at least the durable stores under test', () => { - expect(persistRegistry.length).toBeGreaterThanOrEqual(6); + expect(persistRegistry.length).toBeGreaterThanOrEqual(7); }); it.each( diff --git a/app/__tests__/routstr402BalanceSync.test.ts b/app/__tests__/routstr402BalanceSync.test.ts new file mode 100644 index 000000000..e5b087647 --- /dev/null +++ b/app/__tests__/routstr402BalanceSync.test.ts @@ -0,0 +1,102 @@ +/** + * Regression for the stale-balance 402 loop: the local Routstr balance + * only refreshed after a SUCCESSFUL stream, so once a key drained, the UI + * gated every send against a phantom figure (observed on device: pill at + * 299 sats, server reporting 216 mSats available) and the + * insufficient-balance popup recurred forever. `throwResponseError` now + * syncs the store balance from the 402's parsed `available` mSats. + * + * The 402 body below is byte-shaped like the live response captured in + * log.txt (2026-07-02): FastAPI-style error envelope whose message embeds + * "X mSats required … Y available". + */ + +import { sendMessage } from '@/shared/lib/routstr/api'; +import { useRoutstrStore } from '@/shared/stores/profile/routstrStore'; + +const mockMemory: Record = {}; + +jest.mock('@/shared/lib/cashu/profileScopedStorage', () => ({ + createProfileScopedStorage: () => ({ + getItem: async (k: string) => mockMemory[k] ?? null, + setItem: async (k: string, v: string) => { + mockMemory[k] = v; + }, + removeItem: async (k: string) => { + delete mockMemory[k]; + }, + }), +})); + +jest.mock('@sovranbitcoin/schemas', () => ({ + loggableIssues: (e: { issues: unknown[] }) => e.issues, +})); + +jest.mock('@/shared/lib/logger', () => { + const noop = { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; + return { + apiLog: noop, + storeLog: noop, + aiLog: noop, + log: noop, + applyFileLogging: jest.fn(), + redactError: (e: unknown) => e, + }; +}); + +jest.mock('@/shared/lib/apiClient', () => ({ + buildAbortSignal: () => undefined, + isAbortError: () => false, +})); + +const INSUFFICIENT_BODY = { + error: { + message: 'Insufficient balance: 85577 mSats required for this model. 216 available.', + type: 'insufficient_quota', + code: 'insufficient_balance', + }, +}; + +/** Test stub for the routstr chat/completions fetch (same pattern as + * blossomDelete.test.ts). */ +function stubFetch402(body: unknown) { + // eslint-disable-next-line no-restricted-properties -- test stub for the routstr 402 response + global.fetch = jest.fn( + async () => + new Response(JSON.stringify(body), { + status: 402, + headers: { 'content-type': 'application/json' }, + }) + ) as unknown as typeof fetch; +} + +describe('402 → balance truth-sync', () => { + // eslint-disable-next-line no-restricted-properties -- restore seam for the stub + const realFetch = global.fetch; + afterEach(() => { + // eslint-disable-next-line no-restricted-properties -- restore seam for the stub + global.fetch = realFetch; + }); + + it('overwrites the stale store balance with the 402 available mSats', async () => { + useRoutstrStore.getState().setBalance(299_841); // the phantom figure + stubFetch402(INSUFFICIENT_BODY); + + await expect( + sendMessage('sk-test', [{ role: 'user', content: 'hi' }], { model: 'gemma-4-26b-a4b-it' }) + ).rejects.toMatchObject({ status: 402 }); + + expect(useRoutstrStore.getState().balance).toBe(216); + }); + + it('leaves the balance untouched when the 402 carries no parseable available figure', async () => { + useRoutstrStore.getState().setBalance(299_841); + stubFetch402({ error: { message: 'Payment required', type: 'x' } }); + + await expect( + sendMessage('sk-test', [{ role: 'user', content: 'hi' }], { model: 'any' }) + ).rejects.toMatchObject({ status: 402 }); + + expect(useRoutstrStore.getState().balance).toBe(299_841); + }); +}); diff --git a/app/__tests__/routstrLineup.test.ts b/app/__tests__/routstrLineup.test.ts new file mode 100644 index 000000000..c5cd7bc4b --- /dev/null +++ b/app/__tests__/routstrLineup.test.ts @@ -0,0 +1,219 @@ +/** + * Pins `deriveLineup`'s selection contract against a fixture trimmed from a + * live `/v1/models` snapshot (2026-07-02, 338 models) plus two synthetic + * rows: an xAI rolling alias (the live catalog had none that day, but the + * per-provider dedup rule must stay pinned for all four providers) and a + * disabled OpenAI row. + * + * The old hardcoded TIER_MATRIX rotted silently when the catalog drifted — + * these tests are the tripwire that replaces it: refresh the fixture from + * the live endpoint and any convention change (alias '~' marker, name + * prefixes, pricing shape) fails loudly here instead of rendering + * "cost unavailable" on device. + */ +import fixture from './fixtures/routstr-models.fixture.json'; +import type { RoutstrModel } from '@/shared/lib/routstr/api'; +import { + AI_PROVIDER_IDS, + AI_TIER_IDS, + deriveLineup, + emptyLineup, + lineupHasEntries, + mergeLineupWithLastKnown, + providerIdForModel, + type AiLineup, + type LineupEntry, +} from '@/shared/lib/routstr/lineup'; + +const MODELS = fixture.data as unknown as RoutstrModel[]; + +/** The four TIER_MATRIX ids that no longer existed in the live catalog — + * the confirmed root cause of the "cost unavailable" rows. */ +const DEAD_TIER_MATRIX_IDS = ['claude-3.5-haiku', 'grok-3-mini', 'grok-4.1-fast', 'grok-4']; + +const selectedEntries = (lineup: AiLineup): LineupEntry[] => + AI_PROVIDER_IDS.flatMap((p) => + AI_TIER_IDS.map((t) => lineup[p][t]).filter((e): e is LineupEntry => e != null) + ); + +describe('deriveLineup', () => { + const { lineup, stats } = deriveLineup(MODELS); + + it('fixture predates the dead ids (they must not resurface via a refresh)', () => { + const fixtureIds = new Set(MODELS.map((m) => m.id)); + for (const dead of DEAD_TIER_MATRIX_IDS) expect(fixtureIds.has(dead)).toBe(false); + for (const entry of selectedEntries(lineup)) { + expect(DEAD_TIER_MATRIX_IDS).not.toContain(entry.modelId); + } + }); + + it('selects the documented top-3 per provider (created desc → context desc → id)', () => { + expect(lineup.openai.max?.modelId).toBe('gpt-chat-latest'); + expect(lineup.openai.pro?.modelId).toBe('gpt-5.5-pro'); // created 3s after gpt-5.5 + expect(lineup.openai.auto?.modelId).toBe('gpt-5.5'); + + expect(lineup.claude.max?.modelId).toBe('claude-sonnet-5'); + expect(lineup.claude.pro?.modelId).toBe('claude-fable-5'); + expect(lineup.claude.auto?.modelId).toBe('claude-opus-4.8-fast'); + + expect(lineup.grok.max?.modelId).toBe('grok-build-0.1'); + expect(lineup.grok.pro?.modelId).toBe('grok-4.3'); + expect(lineup.grok.auto?.modelId).toBe('grok-4.20-multi-agent'); // created 139s after grok-4.20 + + expect(lineup.google.max?.modelId).toBe('gemini-3.5-flash'); + expect(lineup.google.pro?.modelId).toBe('gemini-3.1-flash-lite'); + expect(lineup.google.auto?.modelId).toBe('gemma-4-26b-a4b-it'); + }); + + it('resolves exact created ties by context desc, then id asc', () => { + // Synthetic exact tie: strip the 139s created gap between the grok-4.20 + // pair so the alphabetical final tie-break is actually exercised. + const tied = MODELS.filter((m) => m.id.startsWith('grok-4.20')).map((m) => ({ + ...m, + created: 1_774_979_019, + })) as RoutstrModel[]; + const { lineup: tiedLineup } = deriveLineup(tied); + // Identical created + context → 'grok-4.20' < 'grok-4.20-multi-agent'. + expect(tiedLineup.grok.max?.modelId).toBe('grok-4.20'); + expect(tiedLineup.grok.auto?.modelId).toBe('grok-4.20-multi-agent'); + // Context desc beats id when created ties. + const ctxTied = tied.map((m, i) => ({ ...m, context_length: i === 0 ? 500_000 : 2_000_000 })); + const { lineup: ctxLineup } = deriveLineup(ctxTied as RoutstrModel[]); + expect(ctxLineup.grok.max?.contextLength).toBe(2_000_000); + }); + + it("dedups rolling '~' alias rows in favor of dated concrete siblings, for every provider", () => { + const ids = selectedEntries(lineup).map((e) => e.modelId); + for (const alias of [ + 'gpt-mini-latest', + 'gpt-latest', + 'claude-fable-latest', + 'claude-haiku-latest', + 'grok-latest', + 'gemini-pro-latest', + 'gemini-flash-latest', + ]) { + expect(ids).not.toContain(alias); + } + // The alias' concrete twin IS selected (fable pair is byte-identical + // on pricing/context in the live data). + expect(ids).toContain('claude-fable-5'); + expect(stats.perProvider.openai.aliasDropped).toBe(2); + expect(stats.perProvider.claude.aliasDropped).toBe(2); + expect(stats.perProvider.grok.aliasDropped).toBe(1); + expect(stats.perProvider.google.aliasDropped).toBe(2); + }); + + it("backstop: every self-declared rolling-redirect row carries the '~' slug marker (fails at fixture refresh if Routstr changes the alias convention)", () => { + // The dedup keys off canonical_slug's '~' prefix. If Routstr ever stops + // marking alias rows that way, a refreshed fixture will contain rows + // whose description still says "redirects to the latest" without the + // marker — this assertion is the loud failure that prompts a dedup fix + // (distinct models CAN legitimately share byte-identical pricing, e.g. + // claude-fable-5 vs claude-opus-4.8-fast, so pricing equality alone + // cannot be the tripwire). + const redirectRows = MODELS.filter((m) => + (m.description ?? '').toLowerCase().includes('redirects to the latest') + ); + expect(redirectRows.length).toBeGreaterThanOrEqual(7); + for (const row of redirectRows) { + expect(row.canonical_slug?.startsWith('~')).toBe(true); + } + }); + + it('excludes image-output, embeddings, audio-output, disabled, and tiny-context rows', () => { + const ids = selectedEntries(lineup).map((e) => e.modelId); + for (const excluded of [ + 'gpt-5.4-image-2', // output_modalities includes image + 'gemini-3-pro-image', // output_modalities includes image + 'gemini-embedding-2', // output embeddings + 'gpt-audio', // output audio + 'gpt-5.4-mini-disabled', // enabled: false + 'gemma4:31b', // 2048 context + no recognised provider identity + ]) { + expect(ids).not.toContain(excluded); + } + }); + + it('never selects models from providers outside the four-tab set', () => { + const foreign = MODELS.filter((m) => providerIdForModel(m) === null).map((m) => m.id); + expect(foreign.length).toBeGreaterThan(0); + const ids = new Set(selectedEntries(lineup).map((e) => e.modelId)); + for (const id of foreign) expect(ids.has(id)).toBe(false); + }); + + it('flags vision input and carries the compact pricing subset (incl. per-image fee)', () => { + for (const entry of selectedEntries(lineup)) { + expect(entry.visionInput).toBe(true); // every current-gen selection accepts images + expect(typeof entry.satsPricing.max_cost).toBe('number'); + expect(typeof entry.satsPricing.prompt).toBe('number'); + } + // Gemini charges a per-image fee — it must survive into the entry so + // attachment drafts can price it in. + const gemini = lineup.google.max!; + expect(gemini.satsPricing.image).not.toBeNull(); + expect(gemini.displayName).toBe('Gemini 3.5 Flash'); + }); + + it('fills partial providers deterministically (2 → max+auto, 1 → auto, 0 → empty row)', () => { + const twoRow = MODELS.filter((m) => ['grok-build-0.1', 'grok-4.3'].includes(m.id)); + const two = deriveLineup(twoRow).lineup.grok; + expect(two.max?.modelId).toBe('grok-build-0.1'); + expect(two.pro).toBeNull(); + expect(two.auto?.modelId).toBe('grok-4.3'); + + const one = deriveLineup(MODELS.filter((m) => m.id === 'grok-4.3')).lineup.grok; + expect(one.max).toBeNull(); + expect(one.pro).toBeNull(); + expect(one.auto?.modelId).toBe('grok-4.3'); + + const none = deriveLineup(MODELS.filter((m) => providerIdForModel(m) !== 'grok')); + expect(none.lineup.grok).toEqual({ auto: null, pro: null, max: null }); + // Other providers unaffected by one provider being empty. + expect(none.lineup.openai.max?.modelId).toBe('gpt-chat-latest'); + }); + + it('never throws and returns an empty lineup for garbage/empty catalogs', () => { + expect(lineupHasEntries(deriveLineup([]).lineup)).toBe(false); + expect(lineupHasEntries(deriveLineup(null as unknown as RoutstrModel[]).lineup)).toBe(false); + const garbage = [ + null, + 42, + {}, + { id: 7 }, + { id: 'x', name: null, architecture: 'nope', sats_pricing: 'nope' }, + ] as unknown as RoutstrModel[]; + expect(lineupHasEntries(deriveLineup(garbage).lineup)).toBe(false); + }); + + it("recognises no-colon '-latest' display names for grouping (their exclusion is dedup, not a grouping drop)", () => { + const aliasRow = MODELS.find((m) => m.id === 'claude-haiku-latest')!; + expect(providerIdForModel(aliasRow)).toBe('claude'); + // Even with the slug stripped, the display-name fallback still groups it. + const noSlugRow: RoutstrModel = { ...aliasRow, canonical_slug: null }; + expect(providerIdForModel(noSlugRow)).toBe('claude'); + }); +}); + +describe('mergeLineupWithLastKnown', () => { + const { lineup: full } = deriveLineup(MODELS); + + it('substitutes only zero-row providers, marking entries lastKnown', () => { + const { lineup: withoutGrok } = deriveLineup( + MODELS.filter((m) => providerIdForModel(m) !== 'grok') + ); + const merged = mergeLineupWithLastKnown(withoutGrok, full); + expect(merged.grok.max?.modelId).toBe('grok-build-0.1'); + expect(merged.grok.max?.lastKnown).toBe(true); + // Live providers stay live — no lastKnown marker. + expect(merged.openai.max?.lastKnown).toBeUndefined(); + }); + + it('is a no-op without a last-known lineup and cannot fill from an empty one', () => { + const { lineup: withoutGrok } = deriveLineup( + MODELS.filter((m) => providerIdForModel(m) !== 'grok') + ); + expect(mergeLineupWithLastKnown(withoutGrok, null).grok.auto).toBeNull(); + expect(mergeLineupWithLastKnown(withoutGrok, emptyLineup()).grok.auto).toBeNull(); + }); +}); diff --git a/app/__tests__/routstrStorePersistResilience.test.ts b/app/__tests__/routstrStorePersistResilience.test.ts new file mode 100644 index 000000000..b89cce604 --- /dev/null +++ b/app/__tests__/routstrStorePersistResilience.test.ts @@ -0,0 +1,176 @@ +/** + * Persist-tolerance regression for the routstr store's new dynamic-lineup + * and attachment fields. `createMergeWithSchema` runs ONE parse over the + * whole blob and discards it entirely on failure — and this blob holds the + * Routstr apiKey (a bearer credential over a sat balance) plus every chat + * session. One malformed attachment or lineup snapshot must therefore + * degrade in place, never take the blob down (the settingsStore + * balanceSplitVariant wipe is the canonical prior incident). + */ + +const mockMemory: Record = {}; + +jest.mock('@/shared/lib/cashu/profileScopedStorage', () => ({ + createProfileScopedStorage: () => ({ + getItem: async (k: string) => mockMemory[k] ?? null, + setItem: async (k: string, v: string) => { + mockMemory[k] = v; + }, + removeItem: async (k: string) => { + delete mockMemory[k]; + }, + }), +})); + +jest.mock('@sovranbitcoin/schemas', () => ({ + loggableIssues: (e: { issues: unknown[] }) => e.issues, +})); + +jest.mock('@/shared/lib/logger', () => { + const noop = { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; + return { + storeLog: noop, + aiLog: noop, + log: noop, + applyFileLogging: jest.fn(), + redactError: (e: unknown) => e, + }; +}); + +const STORAGE_KEY = 'routstr-store'; + +function preload(state: Record) { + mockMemory[STORAGE_KEY] = JSON.stringify({ state, version: 1 }); +} + +async function loadStore() { + const mod = + require('@/shared/stores/profile/routstrStore') as typeof import('@/shared/stores/profile/routstrStore'); + await mod.useRoutstrStore.persist.rehydrate(); + return mod.useRoutstrStore; +} + +const baseSession = { + id: 'session-1', + title: 'Chat', + createdAt: 1, + messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }], +}; + +describe('routstrStore persist resilience', () => { + beforeEach(() => { + jest.resetModules(); + for (const k of Object.keys(mockMemory)) delete mockMemory[k]; + }); + + it('parses a legacy blob that predates lastKnownLineup and attachments', async () => { + preload({ + apiKey: 'sk-legacy-key', + balance: 1234, + selectedModel: null, + sessions: [baseSession], + currentSessionId: 'session-1', + }); + + const store = await loadStore(); + + expect(store.getState().apiKey).toBe('sk-legacy-key'); + expect(store.getState().sessions).toHaveLength(1); + expect(store.getState().lastKnownLineup).toBeNull(); + // Working copy restored from the active session row. + expect(store.getState().conversationHistory).toHaveLength(1); + }); + + it('keeps apiKey + sessions when a message carries malformed attachments', async () => { + preload({ + apiKey: 'sk-key', + balance: 5, + selectedModel: null, + sessions: [ + { + ...baseSession, + messages: [ + { + id: 'm1', + role: 'user', + content: 'photo turn', + timestamp: 1, + // width as string + missing mimeType/localUri types — the + // array degrades to [] instead of failing the blob parse. + attachments: [{ localUri: 42, mimeType: null, width: 'wide' }], + }, + ], + }, + ], + currentSessionId: 'session-1', + }); + + const store = await loadStore(); + + expect(store.getState().apiKey).toBe('sk-key'); + expect(store.getState().sessions).toHaveLength(1); + const message = store.getState().sessions[0].messages[0]; + expect(message.content).toBe('photo turn'); + expect(message.attachments ?? []).toHaveLength(0); + }); + + it('keeps apiKey + sessions when the persisted lineup snapshot is garbage', async () => { + preload({ + apiKey: 'sk-key', + balance: 5, + selectedModel: null, + sessions: [baseSession], + currentSessionId: 'session-1', + lastKnownLineup: { derivedAt: 'yesterday', lineup: 'not-a-lineup' }, + }); + + const store = await loadStore(); + + expect(store.getState().apiKey).toBe('sk-key'); + expect(store.getState().sessions).toHaveLength(1); + // Malformed snapshot degrades to null; the menu re-derives on next fetch. + expect(store.getState().lastKnownLineup).toBeNull(); + }); + + it('round-trips valid attachments and lineup snapshots intact', async () => { + const attachment = { + localUri: 'file:///photos/a.jpg', + mimeType: 'image/jpeg', + width: 100, + height: 200, + }; + const entry = { + modelId: 'claude-sonnet-5', + displayName: 'Claude Sonnet 5', + contextLength: 1_000_000, + created: 1_782_843_083, + visionInput: true, + satsPricing: { prompt: 0.002, completion: 0.01, request: 0, image: null, max_cost: 3742 }, + }; + const provider = { auto: null, pro: null, max: entry }; + const empty = { auto: null, pro: null, max: null }; + preload({ + apiKey: 'sk-key', + balance: 5, + selectedModel: null, + sessions: [ + { + ...baseSession, + messages: [ + { id: 'm1', role: 'user', content: 'photo', timestamp: 1, attachments: [attachment] }, + ], + }, + ], + currentSessionId: 'session-1', + lastKnownLineup: { + derivedAt: 123, + lineup: { openai: empty, claude: provider, grok: empty, google: empty }, + }, + }); + + const store = await loadStore(); + + expect(store.getState().sessions[0].messages[0].attachments).toEqual([attachment]); + expect(store.getState().lastKnownLineup?.lineup.claude.max?.modelId).toBe('claude-sonnet-5'); + }); +}); diff --git a/app/assets/icons/index.tsx b/app/assets/icons/index.tsx index a825af72b..8f3e7ae5e 100644 --- a/app/assets/icons/index.tsx +++ b/app/assets/icons/index.tsx @@ -51,6 +51,10 @@ export const icons: string[] = [ 'ri:openai-fill', // Using robot as OpenAI icon 'ri:anthropic-fill', 'ri:twitter-x-fill', + 'ri:google-fill', + // Model picker dynamic-lineup chrome (vision marker + offline row). + 'mdi:image-outline', + 'mdi:cloud-off-outline', 'mdi:brain', 'mdi:help-circle', 'mdi:check-circle', diff --git a/app/features/ai/components/AiMessageBubble.tsx b/app/features/ai/components/AiMessageBubble.tsx index 8c538c920..edd2f7a57 100644 --- a/app/features/ai/components/AiMessageBubble.tsx +++ b/app/features/ai/components/AiMessageBubble.tsx @@ -2,8 +2,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { View } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import * as Clipboard from 'expo-clipboard'; +import { Image as ExpoImage } from 'expo-image'; import Icon from 'assets/icons'; -import type { RoutstrMessage } from '@/shared/stores/profile/routstrStore'; +import type { ChatAttachment, RoutstrMessage } from '@/shared/stores/profile/routstrStore'; import { Text } from '@/shared/ui/primitives/Text'; import { Spinner } from '@/shared/ui/primitives/Spinner'; import { HStack } from '@/shared/ui/primitives/View/HStack'; @@ -186,6 +187,41 @@ function ThinkingHeader({ ); } +/** + * Thumbnail for one persisted image attachment. Renders from the bounded + * local URI only (base64 never persists); when the file is gone — photo + * deleted, or iOS rotated the app container across a reinstall — the + * `onError` flip shows a broken-image placeholder instead of a blank box. + */ +function AttachmentThumb({ attachment }: { attachment: ChatAttachment }) { + const shade400 = useThemeColor('shade-400'); + const [failed, setFailed] = useState(false); + const onError = useCallback(() => setFailed(true), []); + if (failed) { + return ( + + + + ); + } + return ( + + ); +} + function UserBubble({ message }: { message: RoutstrMessage }) { const [foreground, surfaceSecondary, shade400, shade500] = useThemeColor([ 'foreground', @@ -194,8 +230,16 @@ function UserBubble({ message }: { message: RoutstrMessage }) { 'shade-500', ] as const); const isSending = message.pending === true; + const attachments = message.attachments ?? []; return ( + {attachments.length > 0 ? ( + + {attachments.map((attachment, index) => ( + + ))} + + ) : null} s.modelsCache?.data ?? null); const setCachedModels = useRoutstrStore((s) => s.setCachedModels); const isCacheStale = useRoutstrStore((s) => s.isCacheStale); + const sessionLineup = useRoutstrStore((s) => s.lineup); + const lastKnownLineup = useRoutstrStore((s) => s.lastKnownLineup); + const lineup = sessionLineup ?? lastKnownLineup?.lineup ?? null; + const lineupSource = sessionLineup ? 'live' : lastKnownLineup ? 'persisted' : 'empty'; const [models, setModels] = useState(cachedModels ?? []); + // Balance self-heal on mount. The only other refresh point is the + // post-stream diff in `useAiSend`, which a 402 never reaches — so a + // drained key would otherwise leave the persisted balance stale + // indefinitely and every affordability gate lying (observed: UI at 299 + // sats vs 0.2 sats actually available → endless insufficient-balance + // popups). One fetch per chip mount keeps the pill and picker honest. + const apiKey = useRoutstrStore((s) => s.apiKey); + const setBalance = useRoutstrStore((s) => s.setBalance); + useEffect(() => { + if (!apiKey) return; + let cancelled = false; + checkBalance(apiKey) + .then((data) => { + if (!cancelled) setBalance(data.balance); + }) + .catch(() => { + // Silent — offline keeps the last-known balance, same policy as + // the models fetch below. + }); + return () => { + cancelled = true; + }; + // Mount-only: one refresh per AI-tab session, not per keystroke of + // dependent state. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (cachedModels && !isCacheStale()) { setModels(cachedModels); @@ -77,33 +113,35 @@ export function ModelChip() { const balanceSats = balanceMsats != null ? Math.floor(balanceMsats / 1000) : 0; const currentTier = getTierById(selectedTier); const currentProvider = getProviderById(selectedProvider); - const resolvedModelId = resolveSelectedModel( + const resolvedEntry = resolveSelectedEntry( currentProvider.id, currentTier.id, balanceSats, - models + lineup ); - // Diagnostic snapshot — fires once per (balance, models, slot) change. - // Captures every cell of the (provider, tier) matrix the chip's + // Diagnostic snapshot — fires once per (balance, lineup, slot) change. + // Captures every filled cell of the (provider, tier) matrix the chip's // affordability gate uses, so a "Top up X sats" indicator that doesn't // match send-time behaviour is debuggable from logs alone. useEffect(() => { - if (models.length === 0) return; + if (!lineup) return; const cellSnapshots: Record[] = []; for (const tier of AI_TIERS) { for (const provider of AI_PROVIDERS) { - const modelId = modelIdForSlot(provider.id, tier.id); - const details = getAffordabilityDetails(modelId, balanceSats, models); - const model = models.find((m) => m.id === modelId); + const entry = entryForSlot(lineup, provider.id, tier.id); + if (!entry) continue; // partial provider — cell deliberately empty cellSnapshots.push({ tierId: tier.id, providerId: provider.id, - ...details, - catalog_max_cost_sats: model?.sats_pricing?.max_cost ?? null, - catalog_max_cost_usd: model?.pricing?.max_cost ?? null, - catalog_max_prompt_cost_sats: model?.sats_pricing?.max_prompt_cost ?? null, - catalog_max_completion_cost_sats: model?.sats_pricing?.max_completion_cost ?? null, + modelId: entry.modelId, + lastKnown: entry.lastKnown ?? false, + visionInput: entry.visionInput, + estimatedTurnCostSats: estimateTurnCostSatsFromPricing(entry.satsPricing), + affordable: canAffordPricing(entry.satsPricing, balanceSats), + catalog_max_cost_sats: entry.satsPricing.max_cost, + catalog_image_fee_sats: entry.satsPricing.image, + contextLength: entry.contextLength, }); } } @@ -113,18 +151,23 @@ export function ModelChip() { selectedTier, selectedProvider, buffer: AFFORD_BUFFER, + lineupSource, catalogSize: models.length, cells: cellSnapshots, }); - }, [balanceMsats, balanceSats, models, selectedTier, selectedProvider]); - - const resolvedModelName = getModelDisplayName(resolvedModelId, models); - // Catalog hasn't loaded → `getModelDisplayName` returns the raw id; show - // the tier label in that case so the chip never reads like a dev string. - const friendlyResolvedName = - resolvedModelName === resolvedModelId ? currentTier.label : resolvedModelName; + }, [ + balanceMsats, + balanceSats, + models.length, + lineup, + lineupSource, + selectedTier, + selectedProvider, + ]); - const chipLabel = `${currentTier.label} · ${friendlyResolvedName}`; + // No lineup yet (first run, fetch pending) → show the tier label so the + // chip never reads like a dev string. + const chipLabel = `${currentTier.label} · ${resolvedEntry?.displayName ?? currentTier.label}`; const onPress = useCallback(() => { // Picker has no in-sheet inputs, so gorhom can't lift over an diff --git a/app/features/ai/hooks/useAiSend.ts b/app/features/ai/hooks/useAiSend.ts index 0a4b13937..a5229fe2d 100644 --- a/app/features/ai/hooks/useAiSend.ts +++ b/app/features/ai/hooks/useAiSend.ts @@ -1,10 +1,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useRoutstrStore } from '@/shared/stores/profile/routstrStore'; +import { useRoutstrStore, type ChatAttachment } from '@/shared/stores/profile/routstrStore'; import { useRoutstrTopUpStore } from '@/shared/stores/runtime/routstrTopUpStore'; import { useMintStore } from '@/shared/stores/profile/mintStore'; import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; -import { sendMessage, checkBalance } from '@/shared/lib/routstr/api'; +import { + sendMessage, + checkBalance, + measureMessageContent, + type RoutstrChatMessage, +} from '@/shared/lib/routstr/api'; import { isAbortError } from '@/shared/lib/apiClient'; import { pickFinalizeMessage } from '../lib/finalize'; import { actionMenuPopup, staticPopup, paramPopup } from '@/shared/lib/popup'; @@ -14,13 +19,16 @@ import { EnhancedHaptics } from '@/shared/ui/primitives/Haptics'; import { AFFORD_BUFFER, AUTO_ICON, + estimateTurnCostSats, getAffordabilityDetails, getModelDisplayName, getProviderById, getTierById, - resolveCandidateChainForSlot, - resolveSelectedModel, + resolveCandidateEntries, + resolveSelectedEntry, } from '../lib/format'; +import { assembleApiMessages, stripImageParts } from '../lib/assembleApiMessages'; +import { encodeChatImage } from '../lib/attachments'; import { deriveActivePath, getAncestorsExclusive } from '../lib/branching'; import { clearStreaming, @@ -70,12 +78,32 @@ function isRetryableConnectError(err: unknown): boolean { return status >= 500 && status <= 599; } +/** + * Whether a connect-time failure means THIS model id is bad rather than + * the request as a whole — a retired/unknown id on an OpenAI-compatible + * API surfaces as 404, or 400 with a model-referencing message. These + * must also advance the candidate chain: the dynamic lineup's last-known + * fallback can legitimately hold an id the catalog has since dropped, and + * without this the very first dead candidate would hard-fail the send the + * chain exists to absorb. Auth (401), payment (402), and rate limit (429) + * stay non-retryable — they repeat identically across models. + */ +function isModelRejectedError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const status = (err as { status?: number }).status; + if (status === 404) return true; + if (status !== 400) return false; + const message = (err as { error?: { message?: string } }).error?.message ?? ''; + return /model/i.test(message); +} + /** * Encapsulates the routstr send + stream + balance-refresh flow for the AI * tab. Two entry points share the same streaming core: * - * - `send(text)` appends a new user message under the current active leaf - * and streams the assistant reply. + * - `send(text, attachments?)` appends a new user message (with optional + * image attachments) under the current active leaf and streams the + * assistant reply. * - `retry(messageId)` spawns a *sibling* assistant under the same parent * as `messageId` and streams a fresh response. The returned active * branch flips to the new sibling so the chat list re-derives onto the @@ -163,17 +191,44 @@ export function useAiSend() { const streamIntoPlaceholder = useCallback( async (params: { assistantMessageId: string; - apiMessages: { role: 'user' | 'assistant' | 'system'; content: string }[]; + apiMessages: RoutstrChatMessage[]; + /** `image_url` parts included in `apiMessages` (drives the + * vision-aware candidate filter + request logs). */ + imageCount: number; flowId: string; retriedFromMessageId?: string; pendingUserMessageForTopUp: string; }) => { - const { assistantMessageId, apiMessages, flowId, pendingUserMessageForTopUp } = params; + const { assistantMessageId, flowId, pendingUserMessageForTopUp } = params; + let { apiMessages, imageCount } = params; if (!apiKey) { staticPopup('no-api-key'); return; } + const storeState = useRoutstrStore.getState(); + const balanceBeforeMsats = storeState.balance ?? 0; + const balanceSats = Math.floor(balanceBeforeMsats / 1000); + const tier = getTierById(storeState.selectedTier); + const provider = getProviderById(storeState.selectedProvider); + const cachedModels = storeState.modelsCache?.data ?? []; + // Resolve the (provider, tier) pair against the dynamic lineup — + // live-derived when a catalog fetch has landed this session, else + // the persisted last-known snapshot. `null` only on a true + // first-run-offline (no catalog AND no snapshot): there is + // deliberately no hardcoded id to guess at anymore, so surface it + // instead of burning a round-trip on a dead model. + const lineup = storeState.lineup ?? storeState.lastKnownLineup?.lineup ?? null; + const primaryEntry = resolveSelectedEntry(provider.id, tier.id, balanceSats, lineup); + if (!primaryEntry) { + aiLog.warn('ai.send.no_lineup', { flowId, hasCatalog: cachedModels.length > 0 }); + removeMessages(new Set([assistantMessageId])); + staticPopup('send-message-failed', { + text: 'Models are still loading — check your connection and try again.', + }); + return; + } + // Abort any prior in-flight stream and wait for its balance refresh // to settle so this flow's snapshot reflects the prior call's debit. streamControllerRef.current?.abort(); @@ -183,20 +238,30 @@ export function useAiSend() { const controller = new AbortController(); streamControllerRef.current = controller; - const storeState = useRoutstrStore.getState(); - const balanceBeforeMsats = storeState.balance ?? 0; - const balanceSats = Math.floor(balanceBeforeMsats / 1000); - const tier = getTierById(storeState.selectedTier); - const provider = getProviderById(storeState.selectedProvider); - const cachedModels = storeState.modelsCache?.data ?? []; - // Resolve the (provider, tier) pair against the live catalog, then - // take the affordable head of the same-tier chain across the other - // providers as runtime fallback for connect-time failures. - const primaryModel = resolveSelectedModel(provider.id, tier.id, balanceSats, cachedModels); - const allCandidates = resolveCandidateChainForSlot(provider.id, tier.id, cachedModels); - const primaryIdx = allCandidates.indexOf(primaryModel); - const candidateChain = - primaryIdx >= 0 ? allCandidates.slice(primaryIdx) : [primaryModel, ...allCandidates]; + // Same-tier chain across the other providers as runtime fallback for + // connect-time failures. When the request carries image parts the + // chain is filtered to vision-capable models — failing over an image + // send onto a text-only model would 400 (non-retryable) and hard-fail + // the send instead of walking the chain. + const allEntries = resolveCandidateEntries(provider.id, tier.id, lineup); + const primaryIdx = allEntries.findIndex((e) => e.modelId === primaryEntry.modelId); + let candidateEntries = primaryIdx >= 0 ? allEntries.slice(primaryIdx) : [primaryEntry]; + if (imageCount > 0) { + const visionOnly = candidateEntries.filter((e) => e.visionInput); + if (visionOnly.length > 0) { + candidateEntries = visionOnly; + } else { + // No vision-capable candidate anywhere (e.g. retry of an old + // image turn after switching to a text-only slot). Image parts + // sent to a text-only model 400 non-retryably, so degrade the + // request to text-only rather than guaranteeing a hard fail. + aiLog.warn('ai.attach.no_vision_candidate', { flowId, droppedImages: imageCount }); + apiMessages = stripImageParts(apiMessages); + imageCount = 0; + } + } + const primaryModel = primaryEntry.modelId; + const candidateChain = candidateEntries.map((e) => e.modelId); setStatus({ isSending: true, streamingMessageId: assistantMessageId }); // Captures `Date.now()` for the live "Thinking for X seconds" @@ -226,7 +291,9 @@ export function useAiSend() { ); try { - const apiInputChars = apiMessages.reduce((n, m) => n + m.content.length, 0); + // Text chars + image-part count — never serialises base64 payloads + // into a log line. + const { textChars: apiInputChars, imageParts } = measureMessageContent(apiMessages); const sendStart = performance.now(); aiLog.info('ai.send.request', { flowId, @@ -236,6 +303,7 @@ export function useAiSend() { candidates: candidateChain, historyMessages: apiMessages.length, totalInputChars: apiInputChars, + imageParts, }); // Pre-flight diagnostic: every input the affordability gate @@ -263,6 +331,11 @@ export function useAiSend() { buffer: AFFORD_BUFFER, catalogSize: cachedModels.length, candidates: candidateSnapshots, + // Per-image fees (e.g. Gemini) make an attachment turn cost more + // than the text-only estimate in the per-candidate snapshots. + imageParts, + estimatedTurnCostWithImagesSats: + imageParts > 0 ? estimateTurnCostSats(primaryModel, cachedModels, imageParts) : null, }); let stream: AsyncIterable | undefined; @@ -291,7 +364,8 @@ export function useAiSend() { } catch (err) { lastConnectErr = err; if (isAbortError(err)) throw err; - if (!isRetryableConnectError(err) || i === candidateChain.length - 1) throw err; + const advance = isRetryableConnectError(err) || isModelRejectedError(err); + if (!advance || i === candidateChain.length - 1) throw err; aiLog.warn('ai.send.candidate_failed', { flowId, tier: tier.id, @@ -609,7 +683,7 @@ export function useAiSend() { ); const sendInner = useCallback( - async (userMessage: string) => { + async (userMessage: string, attachments?: ChatAttachment[]) => { const trimmed = userMessage.trim(); if (!trimmed) return; @@ -641,6 +715,7 @@ export function useAiSend() { content: trimmed, timestamp, pending: true, + ...(attachments && attachments.length > 0 ? { attachments } : {}), }); addMessage({ id: assistantMessageId, @@ -652,22 +727,24 @@ export function useAiSend() { // Build context = active path + just-added user message. We read the // freshly-added messages via the active path because the store has - // already absorbed them. + // already absorbed them. Assembly (incl. the inline-image window and + // per-attachment encoding) is shared with retry via + // `assembleApiMessages` so the two flows can't diverge. const stateAfter = useRoutstrStore.getState(); - const apiMessages = deriveActivePath( + const path = deriveActivePath( stateAfter.conversationHistory, stateAfter.activeChildren - ) - .filter((m) => m.id !== assistantMessageId && m.content) - .map((m) => ({ - role: m.role as 'user' | 'assistant' | 'system', - content: m.content, - })); + ).filter((m) => m.id !== assistantMessageId); + const { messages: apiMessages, imageCount } = await assembleApiMessages( + path, + encodeChatImage + ); try { await streamIntoPlaceholder({ assistantMessageId, apiMessages, + imageCount, flowId, pendingUserMessageForTopUp: trimmed, }); @@ -718,14 +795,14 @@ export function useAiSend() { } // Build the context that produced `messageId`: every ancestor up to // and including the user turn that prompted it. Excludes `messageId` - // itself so we generate a *fresh* response. + // itself so we generate a *fresh* response. Same assembly as `send` + // (inline-image window included) so a retry of an image turn re-sends + // the same content parts the original did. const ancestors = getAncestorsExclusive(messageId, stateNow.conversationHistory); - const apiMessages = ancestors - .filter((m) => m.content) - .map((m) => ({ - role: m.role as 'user' | 'assistant' | 'system', - content: m.content, - })); + const { messages: apiMessages, imageCount } = await assembleApiMessages( + ancestors, + encodeChatImage + ); if (apiMessages.length === 0) { aiLog.warn('ai.retry.no_context', { messageId }); return; @@ -757,6 +834,7 @@ export function useAiSend() { await streamIntoPlaceholder({ assistantMessageId: newAssistantId, apiMessages, + imageCount, flowId, retriedFromMessageId: messageId, pendingUserMessageForTopUp: lastUserContent, diff --git a/app/features/ai/lib/assembleApiMessages.ts b/app/features/ai/lib/assembleApiMessages.ts new file mode 100644 index 000000000..2adaca54f --- /dev/null +++ b/app/features/ai/lib/assembleApiMessages.ts @@ -0,0 +1,117 @@ +/** + * Turn a conversation path (active branch or retry ancestry) into the + * OpenAI-compatible message array for `chat/completions`. ONE assembly + * function shared by `useAiSend`'s send and retry flows, so multimodal + * behavior can't diverge between them. + * + * Inline-image window: only images from the newest `MAX_INLINE_IMAGE_TURNS` + * attachment-bearing user turns (capped at `MAX_INLINE_IMAGES` images + * total, newest turns first) are encoded into `image_url` parts. Older + * attachment turns are assembled text-only — without the bound, every + * historical image would be retransmitted in full (~500KB base64 each) on + * every subsequent turn, growing per-request payload linearly with + * conversation length. Persisted attachments and bubble rendering are + * untouched; this is request assembly only. + * + * The encoder is injected (production: the memoized `encodeChatImage`) + * so the windowing/degradation contract is unit-testable without native + * image mocks. An encoder returning `null` (missing/expired local URI) + * degrades that image, never the send. + */ +import type { RoutstrChatMessage, RoutstrContentPart } from '@/shared/lib/routstr/api'; +import type { ChatAttachment, RoutstrMessage } from '@/shared/stores/profile/routstrStore'; +import { aiLog } from '@/shared/lib/logger'; + +/** Newest N attachment-bearing user turns whose images ride inline. */ +export const MAX_INLINE_IMAGE_TURNS = 2; +/** Hard cap on `image_url` parts per request across the window. */ +export const MAX_INLINE_IMAGES = 4; + +type ChatImageEncoder = (attachment: ChatAttachment) => Promise; + +/** + * Drop every `image_url` part, collapsing part-arrays back to plain string + * content. Used when an image-bearing request has NO vision-capable + * candidate to go to (e.g. retrying an old image turn after switching to + * a text-only slot) — sending image parts to a text-only model 400s + * non-retryably, so degrading the whole request to text is the only send + * that can succeed. + */ +export function stripImageParts(messages: RoutstrChatMessage[]): RoutstrChatMessage[] { + return messages.map((m) => { + if (typeof m.content === 'string') return m; + const text = m.content + .filter((p): p is Extract => p.type === 'text') + .map((p) => p.text) + .join('\n'); + return { role: m.role, content: text }; + }); +} + +interface AssembledApiMessages { + messages: RoutstrChatMessage[]; + /** `image_url` parts actually included — drives the vision-aware + * candidate-chain filter and the request logs. */ + imageCount: number; +} + +/** + * @param path Conversation messages in send order (active path for a + * new send; exclusive ancestry for a retry). + * @param encode Attachment → data-URL encoder (memoized in production). + */ +export async function assembleApiMessages( + path: RoutstrMessage[], + encode: ChatImageEncoder +): Promise { + const sendable = path.filter((m) => m.content || (m.attachments?.length ?? 0) > 0); + + // Decide the inline window: indices of the newest attachment-bearing + // user turns, walked newest-first under both the turn and image caps. + const inlineBudget = new Map(); // index → images allowed + let turnsTaken = 0; + let imagesTaken = 0; + for (let i = sendable.length - 1; i >= 0; i--) { + const m = sendable[i]; + if (m.role !== 'user' || !m.attachments?.length) continue; + if (turnsTaken >= MAX_INLINE_IMAGE_TURNS || imagesTaken >= MAX_INLINE_IMAGES) break; + const allowance = Math.min(m.attachments.length, MAX_INLINE_IMAGES - imagesTaken); + inlineBudget.set(i, allowance); + turnsTaken++; + imagesTaken += allowance; + } + + let imageCount = 0; + const messages: RoutstrChatMessage[] = []; + for (let i = 0; i < sendable.length; i++) { + const m = sendable[i]; + const role = m.role as RoutstrChatMessage['role']; + const allowance = inlineBudget.get(i) ?? 0; + if (allowance === 0) { + // Outside the window (or no attachments): plain string content, the + // exact shape every pre-image conversation already sent. + messages.push({ role, content: m.content }); + continue; + } + const parts: RoutstrContentPart[] = []; + if (m.content) parts.push({ type: 'text', text: m.content }); + for (const attachment of m.attachments!.slice(0, allowance)) { + const dataUrl = await encode(attachment); + if (dataUrl == null) { + aiLog.warn('ai.attach.encode_missing', { + reason: 'assembly_degraded', + messageId: m.id, + }); + continue; + } + parts.push({ type: 'image_url', image_url: { url: dataUrl } }); + imageCount++; + } + // All images degraded and no text → nothing sendable in this turn; + // fall back to the (possibly empty) string so the turn keeps its slot + // in the conversation instead of silently vanishing. + messages.push(parts.length > 0 ? { role, content: parts } : { role, content: m.content }); + } + + return { messages, imageCount }; +} diff --git a/app/features/ai/lib/attachments.ts b/app/features/ai/lib/attachments.ts new file mode 100644 index 000000000..4c6204220 --- /dev/null +++ b/app/features/ai/lib/attachments.ts @@ -0,0 +1,141 @@ +/** + * Image attach for the AI chat composer: pick + encode. + * + * Pick mirrors the Nostr `PostComposer` UX exactly (library-only picker, + * `exif: false`, defer all processing to send time — a mis-pick costs no + * work and the user can remove it before anything is encoded). + * + * Encode is deliberately NOT `normalizeImageAsset`: the Nostr path's + * contract is a full-resolution *file* for Blossom upload, while chat + * needs a *downscaled inline base64 data-URL* — Routstr's OpenAI-compatible + * `chat/completions` decodes `image_url` data-URLs server-side and there + * is no upload endpoint. It reuses the identical `expo-image-manipulator` + * re-encode pipeline though, so EXIF/GPS stripping happens the same proven + * way (PR #242 pattern): a full re-encode drops all metadata. + */ +import * as ImagePicker from 'expo-image-picker'; +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'; + +import { aiLog } from '@/shared/lib/logger'; +import type { ChatAttachment } from '@/shared/stores/profile/routstrStore'; + +/** Longest-edge cap for inline chat images. Vision endpoints tile images + * (~512px tiles) and gain nothing from more pixels than this, while the + * base64 payload grows quadratically — 1536px keeps a photo around + * 200–400KB encoded. */ +const MAX_LONGEST_EDGE = 1536; + +/** JPEG quality for the inline re-encode. Slightly below the Nostr path's + * 0.9 — model input tolerates compression artifacts better than humans + * viewing a full-screen photo, and the payload travels on every turn in + * the inline window. */ +const JPEG_COMPRESS = 0.85; + +/** + * Session memo of encoded data-URLs keyed by local URI, so historical + * image turns are read + re-encoded ONCE, not on every subsequent + * send/retry that reassembles the conversation. LRU-capped: ~8 entries × + * ~400KB is a bounded few MB; oldest evicts first. In-memory only — the + * base64 never touches persistence. + */ +const ENCODE_CACHE_MAX = 8; +const encodeCache = new Map(); + +function cacheGet(key: string): string | undefined { + const hit = encodeCache.get(key); + if (hit !== undefined) { + // Refresh recency (Map iteration order is insertion order). + encodeCache.delete(key); + encodeCache.set(key, hit); + } + return hit; +} + +function cachePut(key: string, value: string): void { + encodeCache.delete(key); + encodeCache.set(key, value); + if (encodeCache.size > ENCODE_CACHE_MAX) { + const oldest = encodeCache.keys().next().value; + if (oldest !== undefined) encodeCache.delete(oldest); + } +} + +/** + * Open the system photo library and return the picked image's bounded + * metadata, or `null` when the user cancels. Same picker call as + * `PostComposer.handleAddMedia`. + */ +export async function pickChatImage(): Promise { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 1, + exif: false, + }); + if (result.canceled || !result.assets[0]) return null; + const asset = result.assets[0]; + aiLog.info('ai.attach.picked', { + mimeType: asset.mimeType ?? 'image/jpeg', + width: asset.width, + height: asset.height, + }); + return { + localUri: asset.uri, + mimeType: asset.mimeType ?? 'image/jpeg', + width: asset.width, + height: asset.height, + }; +} + +/** + * Re-encode an attachment into an inline `data:image/jpeg;base64,…` URL: + * EXIF/GPS-stripped (full re-encode), downscaled to ≤1536px longest edge, + * memoized per session. Returns `null` when the local URI is missing or + * unreadable (photo deleted, iOS container path rotated across a + * reinstall) — callers degrade that turn to text-only rather than failing + * the send. + */ +export async function encodeChatImage(attachment: ChatAttachment): Promise { + const cached = cacheGet(attachment.localUri); + if (cached !== undefined) return cached; + const start = performance.now(); + try { + const context = ImageManipulator.manipulate(attachment.localUri); + const longest = Math.max(attachment.width || 0, attachment.height || 0); + if (longest > MAX_LONGEST_EDGE) { + // Single-dimension resize preserves aspect ratio. + const resize = + (attachment.width || 0) >= (attachment.height || 0) + ? { width: MAX_LONGEST_EDGE } + : { height: MAX_LONGEST_EDGE }; + context.resize(resize); + } + const rendered = await context.renderAsync(); + const saved = await rendered.saveAsync({ + format: SaveFormat.JPEG, + compress: JPEG_COMPRESS, + base64: true, + }); + if (!saved.base64) { + aiLog.warn('ai.attach.encode_missing', { reason: 'no_base64' }); + return null; + } + const dataUrl = `data:image/jpeg;base64,${saved.base64}`; + cachePut(attachment.localUri, dataUrl); + aiLog.info('ai.attach.encoded', { + fromW: attachment.width, + fromH: attachment.height, + toW: saved.width, + toH: saved.height, + encodedChars: dataUrl.length, + duration_ms: Math.round((performance.now() - start) * 100) / 100, + }); + return dataUrl; + } catch (err) { + // Expired/deleted URI is an expected lifecycle event, not a send error. + aiLog.warn('ai.attach.encode_missing', { + reason: 'read_failed', + err: err instanceof Error ? err.message : String(err), + }); + return null; + } +} diff --git a/app/features/ai/lib/format.ts b/app/features/ai/lib/format.ts index 4217c3a66..2bdbd2405 100644 --- a/app/features/ai/lib/format.ts +++ b/app/features/ai/lib/format.ts @@ -1,29 +1,42 @@ import type { RoutstrModel } from '@/shared/lib/routstr/api'; +import { + AI_PROVIDER_IDS, + AI_TIER_IDS, + type AiLineup, + type AiProviderId, + type AiTierId, + type LineupEntry, + type LineupPricing, +} from '@/shared/lib/routstr/lineup'; /** - * Model picker shown in the AI tab. The picker is a 3 × 3 matrix: - * three providers (OpenAI, Claude, Grok) crossed with three quality tiers - * (Auto, Pro, Max). The user picks a (provider, tier) pair via the menu — - * tabs at the top of the sheet are providers, rows under each tab are - * tiers — and the runtime resolves that pair to a single model id via - * `TIER_MATRIX`. + * Model picker shown in the AI tab. The picker is a providers × tiers + * matrix: four providers (OpenAI, Claude, Grok, Google) crossed with three + * quality tiers (Auto, Pro, Max). The user picks a (provider, tier) pair + * via the menu — tabs at the top of the sheet are providers, rows under + * each tab are tiers — and the runtime resolves that pair to a model id + * against the DYNAMIC lineup derived from the live `/v1/models` catalog + * (see `shared/lib/routstr/lineup.ts`). * - * Tier intent (cheapest → premium): - * - Auto — daily-driver chat, kept under ~100 sats / msg. - * - Pro — frontier reasoning at low latency. - * - Max — most capable, for code and deep reasoning. + * There is deliberately no hardcoded model-id table here anymore. The old + * `TIER_MATRIX` rotted as the catalog drifted (4 of its 9 ids stopped + * existing, rendering "cost unavailable" rows), and its stated intent — + * "retune the lineup without a persisted migration" — is preserved by the + * derivation: the AI tab's (provider id, tier id) selection is + * session-only state, so a lineup change never needs a migration. (The + * persisted `selectedModel` id belongs to the legacy UserMessagesScreen + * surface, not this picker.) Pricing is NEVER hardcoded — every sat figure + * rendered to the user is read from the catalog's `sats_pricing` (or the + * persisted lineup snapshot's compact copy of it) at display time. * - * Edit `TIER_MATRIX` to retune the lineup without a persisted migration — - * the persisted state is just a (provider id, tier id) pair, never a - * model id, so swapping `gpt-5.4-mini` for `gpt-5.4-mini-vNext` is - * invisible to the user. Pricing is NEVER hardcoded — every sat figure - * rendered to the user is read from `RoutstrModel.sats_pricing` at - * display time. + * Tier intent under the capability ordering (see `deriveLineup`): + * - Max — rank 1: the provider's newest / most capable model. + * - Pro — rank 2. + * - Auto — rank 3: the accessible daily-driver end of the top-3. + * Per-row live cost display carries the price signal the old + * cheapest→premium ladder used to encode. */ -export type AiProviderId = 'openai' | 'claude' | 'grok'; -type AiTierId = 'auto' | 'pro' | 'max'; - export interface AiProvider { id: AiProviderId; /** Tab label shown in the picker's anchor pill. */ @@ -42,10 +55,13 @@ export interface AiTier { icon: string; } +/** Ordered to match `AI_PROVIDER_IDS` — the provider-id union and this + * display metadata stay in lockstep by construction (same source array). */ export const AI_PROVIDERS: readonly AiProvider[] = [ { id: 'openai', label: 'OpenAI', icon: 'ri:openai-fill' }, { id: 'claude', label: 'Claude', icon: 'ri:anthropic-fill' }, { id: 'grok', label: 'Grok', icon: 'ri:twitter-x-fill' }, + { id: 'google', label: 'Google', icon: 'ri:google-fill' }, ] as const; export const AI_TIERS: readonly AiTier[] = [ @@ -69,31 +85,6 @@ export const AI_TIERS: readonly AiTier[] = [ }, ] as const; -/** - * Source-of-truth lineup. Outer key is tier, inner key is provider — - * `TIER_MATRIX[tier][provider]` is the model id we'd send to. Edit - * freely; runtime fallback through other providers in the same tier is - * handled by `buildCandidateChain` so a missing entry in any one cell - * just removes that fallback hop. - */ -const TIER_MATRIX: Readonly>>> = { - auto: { - openai: 'gpt-5-nano', - claude: 'claude-3.5-haiku', - grok: 'grok-3-mini', - }, - pro: { - openai: 'gpt-5.4-mini', - claude: 'claude-haiku-4.5', - grok: 'grok-4.1-fast', - }, - max: { - openai: 'gpt-5.4', - claude: 'claude-sonnet-4.6', - grok: 'grok-4', - }, -} as const; - const DEFAULT_PROVIDER_ID: AiProviderId = 'openai'; /** Default tier on app start — also the fallback when a stale (provider, @@ -137,13 +128,78 @@ const TYPICAL_PROMPT_TOKENS = 8000; */ const TYPICAL_COMPLETION_TOKENS = 2000; +function pricingForModel(modelId: string, models: RoutstrModel[]): LineupPricing | null { + const model = models.find((m) => m.id === modelId); + const p = model?.sats_pricing; + if (!p) return null; + const num = (v: unknown): number | null => (typeof v === 'number' ? v : null); + return { + prompt: num(p.prompt), + completion: num(p.completion), + request: num(p.request), + image: num(p.image), + max_cost: num(p.max_cost), + }; +} + +/** + * Estimate a realistic worst-case cost for ONE chat turn, in whole sats, + * from a compact pricing record — works identically against a live catalog + * row (`sats_pricing`) or a persisted lineup entry, which is what keeps + * the picker's cost column alive offline. + * + * `request` (per-call fee) + `prompt` × `TYPICAL_PROMPT_TOKENS` + + * `completion` × `TYPICAL_COMPLETION_TOKENS` + `image` × `imageCount` + * (the per-image fee some vision models charge — attachment drafts on + * Gemini are systematically undercounted without it). + * + * Why not `max_cost`? `max_cost` is "fill the entire context window with + * the most expensive token mix". For a 400k-token context that works out + * to ~84 sats where a real turn costs ~0.2 sats; gating the affordability + * indicator on it produced "Top up X sats" banners on tiers the user + * could fund 100+ messages against. + * + * Falls back to `max_cost / 100` (a ~hundredth of the worst case is a + * reasonable typical-turn ballpark) when per-token pricing is missing, + * and to `null` when pricing is unknown entirely so the caller can + * short-circuit to "always affordable until proven otherwise". + */ +export function estimateTurnCostSatsFromPricing( + pricing: LineupPricing | null, + imageCount = 0 +): number | null { + if (!pricing) return null; + const imageFee = typeof pricing.image === 'number' ? pricing.image * imageCount : 0; + if (pricing.prompt != null && pricing.completion != null) { + return ( + (pricing.request ?? 0) + + pricing.prompt * TYPICAL_PROMPT_TOKENS + + pricing.completion * TYPICAL_COMPLETION_TOKENS + + imageFee + ); + } + if (pricing.max_cost != null) { + return pricing.max_cost / 100 + imageFee; + } + return null; +} + +/** Catalog-keyed variant of `estimateTurnCostSatsFromPricing` for the send + * path, which always works against live `RoutstrModel[]` rows. */ +export function estimateTurnCostSats( + modelId: string, + models: RoutstrModel[], + imageCount = 0 +): number | null { + return estimateTurnCostSatsFromPricing(pricingForModel(modelId, models), imageCount); +} + /** - * Approximate count of additional turns the user could send against - * `modelId` before Routstr's `max_cost` reservation gate starts rejecting. + * Approximate count of additional turns the user could send against this + * pricing before Routstr's `max_cost` reservation gate starts rejecting. * Two limits are at play: * - * 1. Each turn drops the balance by `estimateTurnCostSats` (the actual - * per-turn spend after the reservation refund). + * 1. Each turn drops the balance by the estimated per-turn spend. * 2. Each request requires `balance >= max_cost` upfront — once the * balance drifts below that ceiling, the next send fails with 402 * regardless of how cheap the typical turn is. @@ -154,54 +210,17 @@ const TYPICAL_COMPLETION_TOKENS = 2000; * cost data is unavailable, and `0` when balance is already below the * reservation floor. */ -export function estimateMessagesRemaining( +export function estimateMessagesRemainingFromPricing( balanceSats: number, - modelId: string, - models: RoutstrModel[] + pricing: LineupPricing | null ): number | null { - const max = maxCostSats(modelId, models); - const typical = estimateTurnCostSats(modelId, models); + const max = pricing?.max_cost ?? null; + const typical = estimateTurnCostSatsFromPricing(pricing); if (max == null || typical == null || typical <= 0) return null; if (balanceSats < max) return 0; return Math.floor((balanceSats - max) / typical) + 1; } -/** - * Estimate a realistic worst-case cost for ONE chat turn against `modelId`, - * in whole sats. Uses the per-token pricing the Routstr `/models` endpoint - * publishes — `request` (per-call fee) + `prompt` × `TYPICAL_PROMPT_TOKENS` - * + `completion` × `TYPICAL_COMPLETION_TOKENS`. - * - * Why not `max_cost`? `max_cost` is "fill the entire context window with - * the most expensive token mix". For a 400k-token gpt-5-nano context that - * works out to ~84 sats; in practice a turn costs ~0.2 sats. Gating the - * affordability indicator on `max_cost` was producing "Top up X sats" - * banners on tiers the user could fund 100+ messages against. - * - * Falls back to `max_cost / 100` (a ~hundredth of the worst case is a - * reasonable typical-turn ballpark) when per-token pricing is missing, - * and to `null` when the model is unknown to the catalog so the caller - * can short-circuit to "always affordable until proven otherwise". - */ -export function estimateTurnCostSats(modelId: string, models: RoutstrModel[]): number | null { - const model = models.find((m) => m.id === modelId); - if (!model) return null; - const pricing = model.sats_pricing; - if (!pricing) return null; - const promptPer = typeof pricing.prompt === 'number' ? pricing.prompt : null; - const completionPer = typeof pricing.completion === 'number' ? pricing.completion : null; - const request = typeof pricing.request === 'number' ? pricing.request : 0; - if (promptPer != null && completionPer != null) { - const cost = - request + promptPer * TYPICAL_PROMPT_TOKENS + completionPer * TYPICAL_COMPLETION_TOKENS; - return cost; - } - if (typeof pricing.max_cost === 'number') { - return pricing.max_cost / 100; - } - return null; -} - /** * One-shot diagnostic snapshot of "is this model affordable right now?". * The gate tracks Routstr's `max_cost` reservation requirement (so the UI @@ -267,119 +286,119 @@ export function getTierById(id: AiTierId | string | null | undefined): AiTier { return TIER_BY_ID.get(DEFAULT_TIER_ID)!; } -/** Look up the model id for a (provider, tier) pair. Always returns a - * defined string because `TIER_MATRIX` is exhaustive. */ -export function modelIdForSlot(provider: AiProviderId, tier: AiTierId): string { - return TIER_MATRIX[tier][provider]; +/** The lineup entry a (provider, tier) pair points at, or `null` when the + * cell is unfilled (partial provider, or no lineup at all yet). */ +export function entryForSlot( + lineup: AiLineup | null, + provider: AiProviderId, + tier: AiTierId +): LineupEntry | null { + return lineup?.[provider]?.[tier] ?? null; } /** - * Ordered list of model ids to try at send time for a (provider, tier) - * pair. The user's selected provider goes first; the same tier from the - * other providers follows in `AI_PROVIDERS` order, providing transparent - * fallback when the primary's network round-trip fails (5xx / network). + * Ordered candidate entries to try at send time for a (provider, tier) + * pair. The user's selected cell goes first; the same tier from the other + * providers follows in `AI_PROVIDER_IDS` order (transparent fallback when + * the primary's round-trip fails with 5xx/network); then, only if the + * whole tier row is empty, the selected provider's other tiers and finally + * everything else — so the send path always has *something* to attempt as + * long as one lineup cell anywhere is filled. Unfilled cells drop out; + * duplicates (impossible within a provider, defensive across the merge + * path) dedup by model id. * - * Ordering rationale: the user picked their preferred provider explicitly, - * so honour it. Falling back across providers (Claude → Grok → OpenAI in - * the same Auto tier) is way better than hard-failing — the user just - * wants a working chat. + * Ordering rationale: the user picked their provider explicitly, so + * honour it. Falling back across providers in the same tier is way better + * than hard-failing — the user just wants a working chat. */ -function buildCandidateChain(provider: AiProviderId, tier: AiTierId): string[] { - const primary = modelIdForSlot(provider, tier); - const fallbacks = AI_PROVIDERS.filter((p) => p.id !== provider).map((p) => - modelIdForSlot(p.id, tier) - ); - return [primary, ...fallbacks]; -} - -/** - * Resolve a (provider, tier) pair to the first model id we'd actually - * send to right now. Walks the candidate chain returned by - * `buildCandidateChain`, preferring entries that are present in the live - * catalog AND affordable. Falls back to "in-catalog at any cost" and - * finally to "first listed" so the chip and send path always have a - * model id, even before the catalog finishes loading. - */ -export function resolveSelectedModel( +export function resolveCandidateEntries( provider: AiProviderId, tier: AiTierId, - balanceSats: number, - models: RoutstrModel[] -): string { - const chain = buildCandidateChain(provider, tier); - if (models.length === 0) return chain[0]; - for (const c of chain) { - if (!models.some((m) => m.id === c)) continue; - if (canAffordModel(c, balanceSats, models)) return c; + lineup: AiLineup | null +): LineupEntry[] { + if (!lineup) return []; + const tierOrder: AiTierId[] = [tier, ...AI_TIER_IDS.filter((t) => t !== tier)]; + const providerOrder: AiProviderId[] = [ + provider, + ...AI_PROVIDER_IDS.filter((p) => p !== provider), + ]; + const seen = new Set(); + const out: LineupEntry[] = []; + for (const t of tierOrder) { + for (const p of providerOrder) { + const entry = lineup[p]?.[t]; + if (!entry || seen.has(entry.modelId)) continue; + seen.add(entry.modelId); + out.push(entry); + } } - for (const c of chain) { - if (models.some((m) => m.id === c)) return c; - } - return chain[0]; + return out; } /** - * In-catalog candidate chain for runtime fallback at send time. Same - * shape as `buildCandidateChain` but filtered to ids actually present in - * the catalog (so we don't try a retired model and waste a round-trip). - * Falls through to the unfiltered list when the catalog is empty so a - * cold start still has something to attempt. + * Resolve a (provider, tier) pair to the lineup entry we'd actually send + * to right now: the first affordable candidate, else the first candidate + * at any cost, else `null` — which only happens on a true first-run-offline + * (no live catalog AND no persisted lineup). Callers must handle `null` by + * rendering a "models loading" state instead of sending to a guessed id; + * the old TIER_MATRIX guarantee of "always a defined string" is deliberately + * relaxed here because a guessed hardcoded id is exactly the rot this + * change removes. */ -export function resolveCandidateChainForSlot( +export function resolveSelectedEntry( provider: AiProviderId, tier: AiTierId, - models: RoutstrModel[] -): string[] { - const chain = buildCandidateChain(provider, tier); - if (models.length === 0) return chain; - const inCatalog = chain.filter((id) => models.some((m) => m.id === id)); - return inCatalog.length > 0 ? inCatalog : chain; + balanceSats: number, + lineup: AiLineup | null +): LineupEntry | null { + const chain = resolveCandidateEntries(provider, tier, lineup); + if (chain.length === 0) return null; + for (const entry of chain) { + if (canAffordPricing(entry.satsPricing, balanceSats)) return entry; + } + return chain[0]; } /** Worst-case cost in whole sats for a given model id, or null if unknown. * Read straight from the catalog's `sats_pricing.max_cost`. Reserved for * *diagnostic* / debug log paths — affordability decisions should use * `estimateTurnCostSats` instead, which reflects realistic per-turn - * spend. See the comment on `estimateTurnCostSats` for the rationale. */ -export function maxCostSats(modelId: string, models: RoutstrModel[]): number | null { + * spend. See the comment on `estimateTurnCostSatsFromPricing`. */ +function maxCostSats(modelId: string, models: RoutstrModel[]): number | null { const m = models.find((mm) => mm.id === modelId); const cost = m?.sats_pricing?.max_cost; return typeof cost === 'number' ? cost : null; } /** - * True if `balanceSats` covers Routstr's reservation requirement for - * `modelId`. Routstr 402s any request where balance < `max_cost`, so this - * gate has to track `max_cost` exactly — gating on the per-turn estimate - * (which is what the user will actually be charged after refunds) lets the - * UI mark tiers as affordable that the API will reject upfront. + * True if `balanceSats` covers Routstr's reservation requirement. Routstr + * 402s any request where balance < `max_cost`, so this gate has to track + * `max_cost` exactly — gating on the per-turn estimate (which is what the + * user will actually be charged after refunds) lets the UI mark tiers as + * affordable that the API will reject upfront. * * Falls back to `true` when cost data is unavailable (cache not populated) * so the picker isn't entirely blank on first paint. */ -export function canAffordModel( - modelId: string, - balanceSats: number, - models: RoutstrModel[] -): boolean { - const cost = maxCostSats(modelId, models); +export function canAffordPricing(pricing: LineupPricing | null, balanceSats: number): boolean { + const cost = pricing?.max_cost ?? null; if (cost == null) return true; return balanceSats >= cost * AFFORD_BUFFER; } /** - * Whole-sat shortfall to unlock `modelId`'s tier — the mirror of - * `canAffordModel`. Mirrors Routstr's reservation requirement (`max_cost`) - * so the "Top up X sats" copy reflects what the API will actually accept. - * Returns `null` when cost data is unknown or already covered. Clamps the - * result to ≥ 1 sat so we never render "Top up 0 more sats" after rounding. + * Whole-sat shortfall to unlock this pricing's tier — the mirror of + * `canAffordPricing`. Mirrors Routstr's reservation requirement + * (`max_cost`) so the "Top up X sats" copy reflects what the API will + * actually accept. Returns `null` when cost data is unknown or already + * covered. Clamps the result to ≥ 1 sat so we never render "Top up 0 more + * sats" after rounding. */ -export function topUpDeficitSats( - modelId: string, - balanceSats: number, - models: RoutstrModel[] +export function topUpDeficitSatsFromPricing( + pricing: LineupPricing | null, + balanceSats: number ): number | null { - const cost = maxCostSats(modelId, models); + const cost = pricing?.max_cost ?? null; if (cost == null) return null; const required = Math.ceil(cost * AFFORD_BUFFER); if (balanceSats >= required) return null; @@ -390,7 +409,10 @@ export function topUpDeficitSats( * Display-friendly model name pulled directly from the Routstr catalog's * `name` field, with the provider prefix stripped. Used wherever we want * the user to see the actual model behind a tier label — e.g. the chip - * subtitle, or each tier row's description. + * subtitle, or each tier row's description. Lineup entries carry the same + * value precomputed (`displayName`) for offline rendering; this catalog + * variant remains for send-path surfaces keyed by raw model id (402 popup, + * fallback logs). * * Examples: * `claude-haiku-4.5` → "Claude Haiku 4.5" (from `name: "Anthropic: Claude Haiku 4.5"`) diff --git a/app/features/ai/screens/AiChatScreen.tsx b/app/features/ai/screens/AiChatScreen.tsx index d53deec6b..0b25d4d68 100644 --- a/app/features/ai/screens/AiChatScreen.tsx +++ b/app/features/ai/screens/AiChatScreen.tsx @@ -5,12 +5,18 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'; import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; import { FlashList } from '@shopify/flash-list'; +import { Image } from 'expo-image'; +import Icon from 'assets/icons'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { PatternBackground } from '@/shared/ui/composed/PatternBackground'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { useSingleFlight } from '@/shared/hooks/useSingleFlight'; -import { useRoutstrStore, type RoutstrMessage } from '@/shared/stores/profile/routstrStore'; +import { + useRoutstrStore, + type ChatAttachment, + type RoutstrMessage, +} from '@/shared/stores/profile/routstrStore'; import { LiquidChatComposer } from '@/shared/ui/composed/chat/LiquidChatComposer'; import { useChatKeyboardAnimationLogger, @@ -26,6 +32,9 @@ import { ModelChip } from '../components/ModelChip'; import { AiEmptyState } from '../components/AiEmptyState'; import { AiMessageBubble, type BranchNav } from '../components/AiMessageBubble'; import { useAiSend } from '../hooks/useAiSend'; +import { resolveSelectedEntry } from '../lib/format'; +import { pickChatImage } from '../lib/attachments'; +import { MAX_INLINE_IMAGES } from '../lib/assembleApiMessages'; import { deriveActivePath, getSiblingInfo, withSynthesisedParents } from '../lib/branching'; const SURFACE = 'ai'; @@ -124,6 +133,27 @@ export function AiChatScreen() { const activeChildren = useRoutstrStore((s) => s.activeChildren); const setActiveBranch = useRoutstrStore((s) => s.setActiveBranch); + // Vision gate for the composer's [+]: image attach is only offered when + // the model the current (provider, tier) slot resolves to accepts image + // input. Falls to disabled while no lineup exists yet (first run before + // the catalog fetch lands). + const selectedTier = useRoutstrStore((s) => s.selectedTier); + const selectedProvider = useRoutstrStore((s) => s.selectedProvider); + const balanceMsats = useRoutstrStore((s) => s.balance); + const sessionLineup = useRoutstrStore((s) => s.lineup); + const lastKnownLineup = useRoutstrStore((s) => s.lastKnownLineup); + const resolvedEntry = useMemo( + () => + resolveSelectedEntry( + selectedProvider, + selectedTier, + balanceMsats != null ? Math.floor(balanceMsats / 1000) : 0, + sessionLineup ?? lastKnownLineup?.lineup ?? null + ), + [selectedProvider, selectedTier, balanceMsats, sessionLineup, lastKnownLineup] + ); + const canAttachImages = resolvedEntry?.visionInput === true; + const { send, retry, isSending, streamingMessageId } = useAiSend(); const activeMessages = useMemo( @@ -179,9 +209,26 @@ export function AiChatScreen() { [retry] ); - // Composer state (draft + measured height for list bottom padding). + // Composer state (draft + pending image attachments + measured height + // for list bottom padding). Attachments accumulate via repeated single + // picks (PostComposer's maxMedia pattern) and clear on successful + // dispatch. const [draft, setDraft] = useState(''); + const [pendingAttachments, setPendingAttachments] = useState([]); const [composerHeight, setComposerHeight] = useState(0); + + // Picked-but-unsent images are only valid against a vision-capable + // model. If the user switches the slot to a text-only model after + // picking, drop them (the strip disappearing is the visible feedback) — + // otherwise submit would carry images the resolved model must reject. + useEffect(() => { + if (canAttachImages) return; + setPendingAttachments((prev) => { + if (prev.length === 0) return prev; + aiLog.info('ai.attach.cleared_on_model_switch', { dropped: prev.length }); + return []; + }); + }, [canAttachImages]); const handleComposerLayout = useCallback((e: LayoutChangeEvent) => { const next = e.nativeEvent.layout.height; setComposerHeight((prev) => (Math.abs(prev - next) > 0.5 ? next : prev)); @@ -224,15 +271,16 @@ export function AiChatScreen() { ], })); - const dispatchSend = useSingleFlight(async (text: string) => { + const dispatchSend = useSingleFlight(async (text: string, attachments: ChatAttachment[]) => { const sendStart = performance.now(); aiLog.info('chat.send.dispatch', { surface: SURFACE, textLen: text.length, + attachmentCount: attachments.length, historyCount: activeMessages.length, }); try { - await send(text); + await send(text, attachments); aiLog.info('chat.send.complete', { surface: SURFACE, duration_ms: Math.round((performance.now() - sendStart) * 100) / 100, @@ -250,12 +298,33 @@ export function AiChatScreen() { const handleSubmit = useCallback(() => { const text = draft.trim(); if (!text) return; + const attachments = pendingAttachments; setDraft(''); - void dispatchSend(text).catch(() => { + setPendingAttachments([]); + void dispatchSend(text, attachments).catch(() => { // Errors already logged; consumer's onSend is expected to surface // user-visible feedback (popups/banners). }); - }, [draft, dispatchSend]); + }, [draft, pendingAttachments, dispatchSend]); + + // [+] → system photo library. Attachments cap at the per-request inline + // budget; the button dims when the resolved model can't accept images. + const handlePlusPress = useCallback(() => { + if (pendingAttachments.length >= MAX_INLINE_IMAGES) { + aiLog.info('ai.attach.limit_reached', { max: MAX_INLINE_IMAGES }); + return; + } + void pickChatImage().then((picked) => { + if (!picked) return; + setPendingAttachments((prev) => + prev.length >= MAX_INLINE_IMAGES ? prev : [...prev, picked] + ); + }); + }, [pendingAttachments.length]); + + const handleRemoveAttachment = useCallback((index: number) => { + setPendingAttachments((prev) => prev.filter((_, i) => i !== index)); + }, []); // Perf loggers — same canonical emits the shared ChatScreen produces, so // the AI surface stays observable in chat.kav.* and chat.list.history_change @@ -361,6 +430,44 @@ export function AiChatScreen() { keyboardLiftStyle, ]}> + {pendingAttachments.length > 0 ? ( + + {pendingAttachments.map((attachment, index) => ( + + + handleRemoveAttachment(index)} + hitSlop={8} + accessibilityLabel="Remove attachment" + accessibilityRole="button" + testID={`ai-attachment-remove-${index}`} + style={{ + position: 'absolute', + top: -6, + right: -6, + backgroundColor: surfaceColor, + borderRadius: 10, + }}> + + + + ))} + + ) : null} diff --git a/app/shared/lib/popup/popups/modelPicker.tsx b/app/shared/lib/popup/popups/modelPicker.tsx index 29f67170d..1347837bb 100644 --- a/app/shared/lib/popup/popups/modelPicker.tsx +++ b/app/shared/lib/popup/popups/modelPicker.tsx @@ -1,7 +1,9 @@ /** * AI tab model picker — tabbed sheet with one tab per provider (OpenAI / - * Claude / Grok), each showing the same three tier rows (Auto / Pro / - * Max) for that provider's curated lineup. + * Claude / Grok / Google), each showing up to three tier rows (Auto / + * Pro / Max) from that provider's dynamically derived lineup (see + * `shared/lib/routstr/lineup.ts`). Rows carry live per-message cost and a + * small image glyph on vision-capable models. * * Why a custom sheet instead of `actionMenuPopup` with sections: * `actionMenuPopup`'s tabbed mode (used by Select Profile) renders all @@ -31,7 +33,7 @@ import opacity from 'hex-color-opacity'; import Icon from 'assets/icons'; import { useRoutstrStore } from '@/shared/stores/profile/routstrStore'; -import type { RoutstrModel } from '@/shared/lib/routstr/api'; +import type { AiProviderId, LineupEntry } from '@/shared/lib/routstr/lineup'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { Text } from '@/shared/ui/primitives/Text'; import { HStack } from '@/shared/ui/primitives/View/HStack'; @@ -41,15 +43,12 @@ import { AI_PROVIDERS, AI_TIERS, type AiProvider, - type AiProviderId, type AiTier, - canAffordModel, - estimateMessagesRemaining, - estimateTurnCostSats, - getModelDisplayName, - maxCostSats, - modelIdForSlot, - topUpDeficitSats, + canAffordPricing, + entryForSlot, + estimateMessagesRemainingFromPricing, + estimateTurnCostSatsFromPricing, + topUpDeficitSatsFromPricing, } from '@/features/ai/lib/format'; import { showActionSheet } from './bridge'; @@ -59,6 +58,9 @@ import type { CustomSheetSharedProps } from '../sheets/types'; const pickerLog = log.child({ module: 'modelPicker' }); +// "cost unavailable" now only renders when a live catalog row genuinely +// ships without pricing — the dynamic lineup can no longer point at a +// model that doesn't exist (the old hardcoded-id failure mode). const formatTypicalCost = (sats: number | null): string => { if (sats == null) return 'cost unavailable'; if (sats >= 1) return `~${Math.round(sats).toLocaleString()} sats / msg`; @@ -69,7 +71,7 @@ const formatTypicalCost = (sats: number | null): string => { interface TierRowProps { tier: AiTier; provider: AiProvider; - models: RoutstrModel[]; + entry: LineupEntry; balanceSats: number; isCurrent: boolean; onPress: () => void; @@ -85,25 +87,32 @@ interface TierRowProps { * neither Trigger, Portal, nor Content is required because Menu.Root * just renders its children inline through a context Provider. */ -function TierRow({ tier, provider, models, balanceSats, isCurrent, onPress }: TierRowProps) { - const modelId = modelIdForSlot(provider.id, tier.id); - const reservationCeiling = maxCostSats(modelId, models); - const typicalCost = estimateTurnCostSats(modelId, models); - const affordable = canAffordModel(modelId, balanceSats, models); - const messagesLeft = estimateMessagesRemaining(balanceSats, modelId, models); - const deficit = !affordable ? topUpDeficitSats(modelId, balanceSats, models) : null; +function TierRow({ tier, provider, entry, balanceSats, isCurrent, onPress }: TierRowProps) { + // Every figure comes from the lineup entry's compact pricing — identical + // math against a live catalog row or the persisted offline snapshot, so + // the picker keeps real prices across an offline relaunch. + const pricing = entry.satsPricing; + const reservationCeiling = pricing.max_cost; + const typicalCost = estimateTurnCostSatsFromPricing(pricing); + const affordable = canAffordPricing(pricing, balanceSats); + const messagesLeft = estimateMessagesRemainingFromPricing(balanceSats, pricing); + const deficit = !affordable ? topUpDeficitSatsFromPricing(pricing, balanceSats) : null; - const modelName = getModelDisplayName(modelId, models); - const friendlyModelName = modelName === modelId ? tier.label : modelName; + const friendlyModelName = entry.displayName || tier.label; // Affordable copy: "{Model} · ~N sats / msg" using the realistic - // per-turn estimate. Unaffordable copy: "{Model} · needs N sats - // reserved" so the deficit number lines up with the API's actual - // reservation requirement. Mirrors `ActionMenuHost.renderActionButton` - // exactly so the picker rows read as the same component family. - const description = affordable - ? `${friendlyModelName} · ${formatTypicalCost(typicalCost)}` - : `${friendlyModelName} · needs ${reservationCeiling != null ? Math.ceil(reservationCeiling).toLocaleString() : '?'} sats reserved`; + // per-turn estimate (text-only baseline — per-image fees apply only to + // attachment drafts and are reflected in the send-path logs, not here). + // Unaffordable copy: "{Model} · needs N sats reserved" so the deficit + // number lines up with the API's actual reservation requirement. + // Mirrors `ActionMenuHost.renderActionButton` exactly so the picker + // rows read as the same component family. Entries substituted from the + // last-known snapshot are annotated — their ids may no longer exist on + // the API, and the send-path candidate chain absorbs that. + const costCopy = affordable + ? formatTypicalCost(typicalCost) + : `needs ${reservationCeiling != null ? Math.ceil(reservationCeiling).toLocaleString() : '?'} sats reserved`; + const description = `${friendlyModelName} · ${costCopy}${entry.lastKnown ? ' · last known' : ''}`; const labelText = affordable && messagesLeft != null && messagesLeft > 0 @@ -146,6 +155,13 @@ function TierRow({ tier, provider, models, balanceSats, isCurrent, onPress }: Ti {descriptionText} + {entry.visionInput ? ( + // Image-input capability marker — the user-facing signal for + // "you can attach photos with this model". + + + + ) : null} {isCurrent ? ( @@ -174,7 +190,13 @@ export function ModelPickerContent({ close }: ModelPickerContentProps) { const selectedProvider = useRoutstrStore((s) => s.selectedProvider); const setSelectedSlot = useRoutstrStore((s) => s.setSelectedSlot); const balanceMsats = useRoutstrStore((s) => s.balance); - const cachedModels = useRoutstrStore((s) => s.modelsCache?.data ?? null); + // Live-derived lineup when a catalog fetch has landed this session, + // else the persisted last-known snapshot, else null (true first-run + // offline → "models loading" rows). + const sessionLineup = useRoutstrStore((s) => s.lineup); + const lastKnownLineup = useRoutstrStore((s) => s.lastKnownLineup); + const lineup = sessionLineup ?? lastKnownLineup?.lineup ?? null; + const lineupSource = sessionLineup ? 'live' : lastKnownLineup ? 'persisted' : 'empty'; // Open onto the user's currently-selected provider tab — they almost // always come here to swap *tier*, not provider, so the active tab @@ -185,7 +207,7 @@ export function ModelPickerContent({ close }: ModelPickerContentProps) { pickerLog.info('modelPicker.mount', { selectedProvider, selectedTier, - catalogSize: cachedModels?.length ?? 0, + lineupSource, }); return () => pickerLog.info('modelPicker.unmount', {}); // Mount-only — we want a single record per open cycle. @@ -193,7 +215,6 @@ export function ModelPickerContent({ close }: ModelPickerContentProps) { }, []); const balanceSats = balanceMsats != null ? Math.floor(balanceMsats / 1000) : 0; - const models = cachedModels ?? []; const activeProvider = useMemo( () => AI_PROVIDERS.find((p) => p.id === activeProviderTab) ?? AI_PROVIDERS[0], [activeProviderTab] @@ -272,17 +293,47 @@ export function ModelPickerContent({ close }: ModelPickerContentProps) { row chrome inside our own BottomSheet host. */} - {AI_TIERS.map((tier) => ( - handleSelect(tier)} - /> - ))} + {(() => { + // Partial-lineup rendering is explicit: only filled tier cells + // get rows (a provider with 2 qualifying models shows 2 rows — + // never duplicated entries, and the tab itself never hides). + // A provider with no entries at all renders one neutral + // loading/empty row instead of a dangling-id lookup. + const rows = AI_TIERS.map((tier) => ({ + tier, + entry: entryForSlot(lineup, activeProvider.id, tier.id), + })).filter((r) => r.entry != null); + if (rows.length === 0) { + return ( + {}}> + + + + + Models loading + + + {lineupSource === 'empty' + ? 'Connect to the internet to load the model list' + : 'No models available for this provider right now'} + + + + + ); + } + return rows.map(({ tier, entry }) => ( + handleSelect(tier)} + /> + )); + })()} diff --git a/app/shared/lib/routstr/api.ts b/app/shared/lib/routstr/api.ts index b2de265b2..239a001bf 100644 --- a/app/shared/lib/routstr/api.ts +++ b/app/shared/lib/routstr/api.ts @@ -56,12 +56,44 @@ type ChatCompletionChunk = z.infer; * `balance` or `data` into non-numbers/non-arrays are rejected before they * reach the wallet UI. */ +// Per-field `.catch(undefined)` keeps the spine tolerant one level deeper +// than the envelope: these are the fields `deriveLineup` consumes, and a +// type drift in any ONE of them must degrade that field (the row then +// fails lineup qualification or ranks last), never reject the whole +// response — rejecting a 200 wholesale would silently drop the entire +// model menu the way the old unvalidated cast silently drifted. +const undef = (schema: T) => schema.optional().catch(undefined); + const ModelsResponseSpine = z .object({ data: z.array( z .object({ enabled: z.boolean().optional(), + created: undef(z.number()), + context_length: undef(z.number()), + name: undef(z.string()), + canonical_slug: undef(z.string().nullable()), + architecture: undef( + z + .object({ + input_modalities: undef(z.array(z.string())), + output_modalities: undef(z.array(z.string())), + }) + .passthrough() + ), + sats_pricing: undef( + z + .object({ + prompt: undef(z.number()), + completion: undef(z.number()), + request: undef(z.number()), + image: undef(z.number()), + max_cost: undef(z.number()), + }) + .passthrough() + .nullable() + ), }) .passthrough() ), @@ -198,6 +230,21 @@ function getUserFriendlyErrorMessage(status: number, errorData: ParsedErrorData) ); } +/** + * Lazy store access. A static import would create an api ↔ store cycle + * (the store imports this module's types and the shared lineup), so the + * store is resolved at call time — via `require` rather than a dynamic + * `import()` because Metro handles both but Jest's CJS VM can only + * execute the former, and the 401/402 branches below are exactly the + * paths that need regression tests. + */ +function routstrStoreState() { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { useRoutstrStore } = + require('@/shared/stores/profile/routstrStore') as typeof import('@/shared/stores/profile/routstrStore'); + return useRoutstrStore.getState(); +} + /** * Throw a typed RoutstrError from a failed fetch Response. * Shared by all API functions to avoid duplicating the parse → format → throw chain. @@ -214,10 +261,25 @@ async function throwResponseError(response: Response): Promise { // 401 with expired/spent key — clear stored API key so user can re-authenticate if (status === 401) { - const { useRoutstrStore } = await import('@/shared/stores/profile/routstrStore'); apiLog.warn('api.routstr.api_key_expired'); - useRoutstrStore.getState().clearApiKey(); - useRoutstrStore.getState().clearBalance(); + routstrStoreState().clearApiKey(); + routstrStoreState().clearBalance(); + } + + // 402 carries the server's true available balance ("X mSats required … + // Y available") — sync it into the store. The local balance otherwise + // only refreshes after a SUCCESSFUL stream, so a drained (or + // reservation-held) key leaves the UI gating sends against a stale + // figure forever: every affordability check passes client-side, every + // send 402s, and the insufficient-balance popup loops. Syncing here + // makes the balance pill, picker fades, and estimates truthful the + // moment the server disagrees. + if (status === 402) { + const available = errorData.details?.available; + if (typeof available === 'number' && isFinite(available) && available >= 0) { + apiLog.info('api.routstr.balance_synced_from_402', { availableMsats: available }); + routstrStoreState().setBalance(available); + } } throw { @@ -543,9 +605,53 @@ function* parseSSEFromText(text: string): Generator { } } +/** + * OpenAI-compatible multimodal message content. Images travel INLINE as + * base64 data-URLs in `image_url` parts — routstr-core decodes data-URLs + * server-side; there is no separate upload endpoint. This is the single + * wire contract for chat content: `sendMessage`, the send/retry assembly + * in `useAiSend`, and every input-length log all consume these types so a + * string-only assumption can't survive anywhere on the path. + */ +export type RoutstrContentPart = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } }; + +export type RoutstrMessageContent = string | RoutstrContentPart[]; + +export interface RoutstrChatMessage { + role: 'user' | 'assistant' | 'system'; + content: RoutstrMessageContent; +} + +/** + * Input-size accounting that never serialises image payloads: text chars + * are summed, image parts are counted. Exported for `useAiSend`'s request + * logs — base64 in a log line would blow the log budget AND trip the + * redaction scanner's large-blob heuristics. + */ +export function measureMessageContent(messages: RoutstrChatMessage[]): { + textChars: number; + imageParts: number; +} { + let textChars = 0; + let imageParts = 0; + for (const m of messages) { + if (typeof m.content === 'string') { + textChars += m.content.length; + continue; + } + for (const part of m.content) { + if (part.type === 'text') textChars += part.text.length; + else imageParts++; + } + } + return { textChars, imageParts }; +} + export async function sendMessage( apiKey: string, - messages: { role: 'user' | 'assistant' | 'system'; content: string }[], + messages: RoutstrChatMessage[], options: { model?: string; temperature?: number; @@ -554,11 +660,12 @@ export async function sendMessage( } = {} ): Promise<{ stream: AsyncIterable }> { const { model = 'gpt-3.5-turbo', temperature = 0.7, max_tokens, signal } = options; - const totalTokens = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0); + const { textChars, imageParts } = measureMessageContent(messages); apiLog.info('api.routstr.chat.start', { model, messageCount: messages.length, - totalInputChars: totalTokens, + totalInputChars: textChars, + imageParts, temperature, max_tokens, }); diff --git a/app/shared/lib/routstr/lineup.ts b/app/shared/lib/routstr/lineup.ts new file mode 100644 index 000000000..02c8a4f52 --- /dev/null +++ b/app/shared/lib/routstr/lineup.ts @@ -0,0 +1,347 @@ +import { z } from 'zod'; +import type { RoutstrModel } from './api'; + +/** + * Dynamic AI-tab model lineup, derived from the live Routstr `/v1/models` + * catalog. Replaces the hand-maintained `TIER_MATRIX` that rotted as the + * catalog drifted (4 of its 9 hardcoded ids no longer existed, which is + * what rendered "cost unavailable" in the picker). + * + * This module is the single seam between the raw catalog and every lineup + * consumer (the picker, the chip, the send path, and the persisted + * last-known snapshot in `routstrStore`). It lives in `shared/lib/routstr` + * — not `features/ai` — because `routstrStore` persists the derived lineup + * and shared stores must not import from features. + * + * `deriveLineup` is pure and total: any input, including a garbage or + * empty catalog, produces a (possibly empty) lineup without throwing. + */ + +/** + * Provider / tier id unions. Single source of truth — `features/ai/lib/ + * format.ts` (UI metadata) and `routstrStore` (selection guards) both + * import these, which is what keeps the two ends of the (provider, tier) + * selection contract in lockstep by construction. + */ +export const AI_PROVIDER_IDS = ['openai', 'claude', 'grok', 'google'] as const; +export type AiProviderId = (typeof AI_PROVIDER_IDS)[number]; + +export const AI_TIER_IDS = ['auto', 'pro', 'max'] as const; +export type AiTierId = (typeof AI_TIER_IDS)[number]; + +/** + * Minimum context window for a model to qualify for the lineup. The + * affordability estimate assumes a typical turn of ~8k prompt + ~2k + * completion tokens (see `format.ts`); a model that cannot comfortably + * hold that plus history is not usable as a chat daily-driver, however + * recent it is. Guards against tiny/toy releases outranking real models + * under the recency-first ordering. + */ +const MIN_CONTEXT_LENGTH = 16_000; + +/** Lineup depth per provider: Auto / Pro / Max. */ +const MAX_PER_PROVIDER = 3; + +/** + * Compact per-model pricing subset carried into the lineup (and persisted + * with it). Field names match the catalog's `sats_pricing` so the pricing + * helpers in `format.ts` work identically against a live catalog row or a + * persisted lineup entry. `image` is the per-image fee some vision models + * (notably Gemini) charge. + */ +const LineupPricingSchema = z.object({ + prompt: z.number().nullable().catch(null), + completion: z.number().nullable().catch(null), + request: z.number().nullable().catch(null), + image: z.number().nullable().catch(null), + max_cost: z.number().nullable().catch(null), +}); +export type LineupPricing = z.infer; + +const LineupEntrySchema = z.object({ + modelId: z.string().max(256), + /** Catalog `name` with the `Provider:` prefix stripped. */ + displayName: z.string().max(256), + contextLength: z.number().int().nonnegative().catch(0), + created: z.number().int().nonnegative().catch(0), + /** Model accepts image input — gates the composer's image attach. */ + visionInput: z.boolean().catch(false), + satsPricing: LineupPricingSchema, + /** + * True when this entry was substituted from the persisted last-known + * lineup because a successful fetch returned zero qualifying models for + * its provider. Rendered as a "last known" annotation; the id may be + * dead on the API, which the send-path candidate chain absorbs. + */ + lastKnown: z.boolean().optional(), +}); +export type LineupEntry = z.infer; + +const ProviderLineupSchema = z.object({ + auto: LineupEntrySchema.nullable().catch(null), + pro: LineupEntrySchema.nullable().catch(null), + max: LineupEntrySchema.nullable().catch(null), +}); +type ProviderLineup = z.infer; + +export const AiLineupSchema = z.object({ + openai: ProviderLineupSchema, + claude: ProviderLineupSchema, + grok: ProviderLineupSchema, + google: ProviderLineupSchema, +}); +export type AiLineup = z.infer; + +/** + * Persisted last-known lineup snapshot — the offline fallback that keeps + * the model menu functional when the catalog fetch fails. Compact by + * construction (≤12 entries), tolerant per field so one malformed value + * can never fail the parse and take the whole `routstr-store` blob (which + * holds the apiKey and sessions) down with it. + */ +export const PersistedLineupSchema = z.object({ + derivedAt: z.number().int().nonnegative().catch(0), + lineup: AiLineupSchema, +}); +export type PersistedLineup = z.infer; + +interface LineupProviderStats { + qualifying: number; + aliasDropped: number; +} + +interface LineupStats { + perProvider: Record; + totalQualifying: number; +} + +const emptyProviderLineup = (): ProviderLineup => ({ auto: null, pro: null, max: null }); + +export const emptyLineup = (): AiLineup => ({ + openai: emptyProviderLineup(), + claude: emptyProviderLineup(), + grok: emptyProviderLineup(), + google: emptyProviderLineup(), +}); + +/** True when at least one cell anywhere in the lineup is filled. */ +export function lineupHasEntries(lineup: AiLineup): boolean { + return AI_PROVIDER_IDS.some((p) => providerHasEntries(lineup[p])); +} + +function providerHasEntries(provider: ProviderLineup): boolean { + return AI_TIER_IDS.some((t) => provider[t] != null); +} + +/** + * Map a catalog slug/name prefix onto our provider ids. The catalog's + * `canonical_slug` prefixes are the most structured identity signal + * (`openai/…`, `anthropic/…`, `x-ai/…`, `google/…`); display-name + * prefixes cover the rows without a slug, including the no-colon alias + * rows ("Anthropic Claude Haiku Latest"). + */ +const SLUG_PREFIX_TO_PROVIDER: Record = { + openai: 'openai', + anthropic: 'claude', + 'x-ai': 'grok', + xai: 'grok', + google: 'google', +}; + +const NAME_PREFIX_TO_PROVIDER: [string, AiProviderId][] = [ + ['openai', 'openai'], + ['anthropic', 'claude'], + ['claude', 'claude'], + ['xai', 'grok'], + ['grok', 'grok'], + ['google', 'google'], + ['gemini', 'google'], +]; + +/** + * Resolve which of our four providers a catalog row belongs to, or `null` + * for everyone else (Qwen, Mistral, DeepSeek, …), who never enter the + * lineup. Slug prefix wins; the display-name fallback deliberately + * recognises alias rows so their later exclusion is an explicit dedup + * decision, not an accidental grouping drop. + */ +export function providerIdForModel(model: RoutstrModel): AiProviderId | null { + const slug = typeof model.canonical_slug === 'string' ? model.canonical_slug : ''; + if (slug) { + // '~' marks a rolling alias slug ('~anthropic/claude-fable-latest'). + const slashIdx = slug.indexOf('/'); + if (slashIdx > 0) { + const prefix = slug.slice(0, slashIdx).replace(/^~/, '').toLowerCase(); + const bySlug = SLUG_PREFIX_TO_PROVIDER[prefix]; + if (bySlug !== undefined) return bySlug; + } + } + const name = typeof model.name === 'string' ? model.name.toLowerCase() : ''; + if (!name) return null; + for (const [prefix, provider] of NAME_PREFIX_TO_PROVIDER) { + if (name.startsWith(prefix)) return provider; + } + return null; +} + +/** Rolling alias rows redirect to "the latest model in the family" and + * duplicate their dated concrete sibling byte-for-byte on pricing — + * keeping both would waste a lineup slot on the same backing model. */ +function isRollingAlias(model: RoutstrModel): boolean { + return typeof model.canonical_slug === 'string' && model.canonical_slug.startsWith('~'); +} + +/** + * Chat-capability qualification: enabled, priced, text-only output (a + * naive top-by-recency otherwise selects image-generation and embedding + * models — `gemini-3-pro-image`, `gemini-embedding-2`), and a context + * window large enough for a typical turn. + */ +function isChatCapable(model: RoutstrModel): boolean { + if (model.enabled !== true) return false; + if (model.sats_pricing == null || typeof model.sats_pricing !== 'object') return false; + const out = model.architecture?.output_modalities; + if (!Array.isArray(out) || !out.includes('text')) return false; + if (out.includes('image') || out.includes('embeddings') || out.includes('audio')) return false; + const ctx = model.context_length; + if (typeof ctx !== 'number' || ctx < MIN_CONTEXT_LENGTH) return false; + return true; +} + +/** + * Capability ordering — the ONE documented ordering used for both top-3 + * selection and tier assignment. The catalog carries no benchmark field, + * so recency (`created` desc) is the primary capability proxy, context + * length desc the secondary, and the id an alphabetical final tie-break + * so exact created+context ties (observed in the live catalog) resolve + * deterministically. + */ +function byCapability(a: RoutstrModel, b: RoutstrModel): number { + const createdA = typeof a.created === 'number' ? a.created : 0; + const createdB = typeof b.created === 'number' ? b.created : 0; + if (createdA !== createdB) return createdB - createdA; + const ctxA = typeof a.context_length === 'number' ? a.context_length : 0; + const ctxB = typeof b.context_length === 'number' ? b.context_length : 0; + if (ctxA !== ctxB) return ctxB - ctxA; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** Catalog `name` with the `Provider:` prefix stripped — same rule as + * `format.ts#getModelDisplayName`, precomputed so persisted entries can + * render offline without a catalog row to look up. */ +function displayNameFor(model: RoutstrModel): string { + const raw = typeof model.name === 'string' ? model.name.trim() : ''; + if (!raw) return model.id; + const colonIdx = raw.indexOf(':'); + if (colonIdx >= 0 && colonIdx < raw.length - 1) return raw.slice(colonIdx + 1).trim(); + return raw; +} + +function toLineupEntry(model: RoutstrModel): LineupEntry { + const p = model.sats_pricing; + const num = (v: unknown): number | null => (typeof v === 'number' && isFinite(v) ? v : null); + return { + modelId: model.id, + displayName: displayNameFor(model), + contextLength: typeof model.context_length === 'number' ? model.context_length : 0, + created: typeof model.created === 'number' ? model.created : 0, + visionInput: + Array.isArray(model.architecture?.input_modalities) && + model.architecture.input_modalities.includes('image'), + satsPricing: { + prompt: num(p?.prompt), + completion: num(p?.completion), + request: num(p?.request), + image: num(p?.image), + max_cost: num(p?.max_cost), + }, + }; +} + +/** + * Assign the ranked top-N to tiers from the SAME capability ordering: + * Max = rank 1 (most capable / newest), Pro = rank 2, Auto = rank 3. + * Partial providers fill deterministically: 2 models → Max + Auto, + * 1 model → Auto only, 0 → all cells null (the provider tab still + * renders, with whatever rows exist). Live per-row cost display carries + * the price signal the old cheapest→premium ladder used to encode. + */ +function assignTiers(ranked: LineupEntry[]): ProviderLineup { + const [first, second, third] = ranked; + if (third) return { max: first, pro: second, auto: third }; + if (second) return { max: first, pro: null, auto: second }; + if (first) return { max: null, pro: null, auto: first }; + return emptyProviderLineup(); +} + +/** + * Derive the (provider × tier) lineup from a raw catalog. Pure and + * total — never throws, returns an empty lineup for garbage input. + */ +export function deriveLineup(models: RoutstrModel[]): { lineup: AiLineup; stats: LineupStats } { + const lineup = emptyLineup(); + const stats: LineupStats = { + perProvider: { + openai: { qualifying: 0, aliasDropped: 0 }, + claude: { qualifying: 0, aliasDropped: 0 }, + grok: { qualifying: 0, aliasDropped: 0 }, + google: { qualifying: 0, aliasDropped: 0 }, + }, + totalQualifying: 0, + }; + if (!Array.isArray(models)) return { lineup, stats }; + + const byProvider = new Map(); + for (const model of models) { + if (!model || typeof model !== 'object' || typeof model.id !== 'string') continue; + const provider = providerIdForModel(model); + if (!provider) continue; + if (!isChatCapable(model)) continue; + if (isRollingAlias(model)) { + // Deliberate within-provider dedup: prefer the dated concrete + // sibling over the rolling '-latest' alias of the same model. + stats.perProvider[provider].aliasDropped++; + continue; + } + const list = byProvider.get(provider) ?? []; + list.push(model); + byProvider.set(provider, list); + } + + for (const provider of AI_PROVIDER_IDS) { + const candidates = (byProvider.get(provider) ?? []).sort(byCapability); + stats.perProvider[provider].qualifying = candidates.length; + stats.totalQualifying += candidates.length; + lineup[provider] = assignTiers(candidates.slice(0, MAX_PER_PROVIDER).map(toLineupEntry)); + } + + return { lineup, stats }; +} + +/** + * Per-provider graceful degradation: when a successful fetch yields zero + * qualifying models for a provider (catalog drift — the exact failure + * class that produced "cost unavailable"), substitute that provider's + * block from the persisted last-known lineup, marking its entries so the + * picker can annotate them. A substituted id may be dead on the API; the + * send-path candidate chain and failed-send popup absorb that. + */ +export function mergeLineupWithLastKnown( + derived: AiLineup, + lastKnown: AiLineup | null | undefined +): AiLineup { + if (!lastKnown) return derived; + const merged = { ...derived }; + for (const provider of AI_PROVIDER_IDS) { + if (providerHasEntries(derived[provider])) continue; + const fallback = lastKnown[provider]; + if (!fallback || !providerHasEntries(fallback)) continue; + const marked = emptyProviderLineup(); + for (const tier of AI_TIER_IDS) { + const entry = fallback[tier]; + marked[tier] = entry ? { ...entry, lastKnown: true } : null; + } + merged[provider] = marked; + } + return merged; +} diff --git a/app/shared/stores/profile/routstrStore.ts b/app/shared/stores/profile/routstrStore.ts index 91346ec93..e92e0efab 100644 --- a/app/shared/stores/profile/routstrStore.ts +++ b/app/shared/stores/profile/routstrStore.ts @@ -3,27 +3,62 @@ import { persist } from 'zustand/middleware'; import { z } from 'zod'; import { createProfileScopedStorage } from '@/shared/lib/cashu/profileScopedStorage'; import { mintLocalId } from '@/shared/lib/id'; -import { storeLog } from '@/shared/lib/logger'; +import { aiLog, storeLog } from '@/shared/lib/logger'; import { RoutstrModel } from '@/shared/lib/routstr/api'; +import { + AI_PROVIDER_IDS, + AI_TIER_IDS, + PersistedLineupSchema, + deriveLineup, + lineupHasEntries, + mergeLineupWithLastKnown, + type AiLineup, + type AiProviderId, + type AiTierId, + type PersistedLineup, +} from '@/shared/lib/routstr/lineup'; import { persistConfig } from '@/shared/lib/persist/persistConfig'; import { restoreActiveSessionView } from '@/shared/stores/profile/restoreActiveSessionView'; -// AI tab tier + provider ids — duplicated as literal types to avoid a -// feature → store → feature import cycle. Kept in lockstep with the -// matching declarations in `features/ai/lib/format.ts`. -type RoutstrTierId = 'auto' | 'pro' | 'max'; -const TIER_IDS: readonly RoutstrTierId[] = ['auto', 'pro', 'max'] as const; +// AI tab tier + provider ids — imported from the shared lineup module, +// which is the single source of truth for both this store's selection +// guards and the display metadata in `features/ai/lib/format.ts`. (These +// used to be duplicated literal unions to avoid a feature → store cycle; +// the shared module removed the need.) +type RoutstrTierId = AiTierId; +const TIER_IDS = AI_TIER_IDS; const DEFAULT_TIER: RoutstrTierId = 'auto'; -type RoutstrProviderId = 'openai' | 'claude' | 'grok'; -const PROVIDER_IDS: readonly RoutstrProviderId[] = ['openai', 'claude', 'grok'] as const; +type RoutstrProviderId = AiProviderId; +const PROVIDER_IDS = AI_PROVIDER_IDS; const DEFAULT_PROVIDER: RoutstrProviderId = 'openai'; +/** + * Image attached to a chat message. Bounded local-URI metadata ONLY — + * never base64 payloads (a 4-image message would be ~2MB of base64; the + * encode happens at send time in `features/ai/lib/attachments.ts` and the + * result never touches persistence or logs). + */ +export interface ChatAttachment { + localUri: string; + mimeType: string; + width: number; + height: number; +} + export interface RoutstrMessage { id: string; role: 'user' | 'assistant'; content: string; timestamp: number; + /** + * Images attached to a user message (≤4). Bubbles render thumbnails from + * the local URIs; the send path re-encodes them into OpenAI-compatible + * `image_url` content parts. A URI can go stale (iOS container-path + * rotation across reinstalls) — renderers show a placeholder and the + * send path degrades that turn to text-only. + */ + attachments?: ChatAttachment[]; /** * Tree-link to the message that prompted this one (user → assistant) or * preceded it (assistant → next user). `null` is the conversation root. @@ -118,6 +153,20 @@ interface RoutstrState { */ selectedProvider: RoutstrProviderId; modelsCache: ModelsCache | null; + /** + * Session-only lineup derived from the last successful catalog fetch + * (already merged with per-provider last-known fallback). `null` until + * the first fetch of this app run; readers fall back to + * `lastKnownLineup?.lineup` and finally to a "models loading" state. + */ + lineup: AiLineup | null; + /** + * Persisted compact snapshot of the most recent derived lineup — the + * offline fallback that keeps the model menu functional (with prices + * and vision flags) when the catalog fetch fails. Bounded (≤12 entries), + * tolerant on parse so it can never take down the rest of this blob. + */ + lastKnownLineup: PersistedLineup | null; sessions: RoutstrSession[]; currentSessionId: string | null; isAnonymousMode: boolean; @@ -181,11 +230,23 @@ interface RoutstrActions { type RoutstrStore = RoutstrState & RoutstrActions; +// Tightly bounded + tolerant: one malformed attachment must never fail the +// whole-blob parse (which would wipe sessions AND the apiKey — the exact +// failure class documented on `createMergeWithSchema`). Invalid arrays +// collapse to [] and drop only the attachments, never the message. +const PersistedChatAttachment = z.object({ + localUri: z.string().max(2048), + mimeType: z.string().max(64), + width: z.number().int().nonnegative().catch(0), + height: z.number().int().nonnegative().catch(0), +}); + const PersistedRoutstrMessage = z.looseObject({ id: z.string().max(128), role: z.enum(['user', 'assistant']), content: z.string().max(65_536), timestamp: z.number().int().nonnegative(), + attachments: z.array(PersistedChatAttachment).max(4).optional().catch([]), parentId: z.string().max(128).nullable().optional(), thinkingDurationSec: z.number().nonnegative().optional(), reasoningContent: z.string().max(65_536).optional(), @@ -207,6 +268,9 @@ const PersistedRoutstrStore = z.object({ selectedModel: z.string().max(256).nullable().default(null), sessions: z.array(PersistedRoutstrSession).max(1024).default([]), currentSessionId: z.string().max(128).nullable().default(null), + // Additive + tolerant (no version bump needed): a malformed snapshot + // parses to null and the menu just re-derives on next fetch. + lastKnownLineup: PersistedLineupSchema.nullable().default(null).catch(null), }); export const useRoutstrStore = create()( @@ -220,6 +284,8 @@ export const useRoutstrStore = create()( selectedTier: DEFAULT_TIER, selectedProvider: DEFAULT_PROVIDER, modelsCache: null, + lineup: null, + lastKnownLineup: null, sessions: [], currentSessionId: null, isAnonymousMode: false, @@ -420,7 +486,31 @@ export const useRoutstrStore = create()( setCachedModels: (models: RoutstrModel[]) => { storeLog.debug('store.routstr.set_cached_models', { count: models.length }); - set({ modelsCache: { data: models, timestamp: Date.now() } }); + // Derive the (provider × tier) lineup alongside the raw cache and + // persist a compact last-known snapshot, so the model menu keeps + // prices + vision flags across offline relaunches. Per-provider + // zero-row results (catalog drift on a 200 — the failure class + // that produced "cost unavailable") substitute from the previous + // snapshot, marked `lastKnown`. + const { lineup: derived, stats } = deriveLineup(models); + const previous = get().lastKnownLineup; + const merged = mergeLineupWithLastKnown(derived, previous?.lineup ?? null); + aiLog.info('ai.lineup.derived', { + catalogSize: models.length, + totalQualifying: stats.totalQualifying, + perProvider: stats.perProvider, + substitutedProviders: PROVIDER_IDS.filter( + (p) => merged[p] !== derived[p] // mergeLineupWithLastKnown replaces the block reference + ), + allProvidersEmpty: !lineupHasEntries(derived), + }); + set({ + modelsCache: { data: models, timestamp: Date.now() }, + lineup: merged, + lastKnownLineup: lineupHasEntries(merged) + ? { derivedAt: Date.now(), lineup: merged } + : previous, + }); }, isCacheStale: () => { @@ -542,6 +632,7 @@ export const useRoutstrStore = create()( selectedModel: state.selectedModel, sessions: state.sessions, currentSessionId: state.currentSessionId, + lastKnownLineup: state.lastKnownLineup, }), afterHydrate: (state) => { if (!state) return; diff --git a/app/shared/ui/composed/chat/LiquidChatComposer.tsx b/app/shared/ui/composed/chat/LiquidChatComposer.tsx index 991ea459d..ad9809673 100644 --- a/app/shared/ui/composed/chat/LiquidChatComposer.tsx +++ b/app/shared/ui/composed/chat/LiquidChatComposer.tsx @@ -60,6 +60,14 @@ interface LiquidChatComposerProps { * consistent across surfaces, but pressing it is a no-op. */ onPlusPress?: () => void; + /** + * Renders the [+] button dimmed and inert while keeping it mounted (the + * three-shape glass layout must not reflow). Used by the AI surface when + * the resolved model lacks vision input — a dimmed affordance reads as + * "exists, unavailable here", where unmounting would read as a layout + * bug and a silent no-op reads as broken. + */ + plusDisabled?: boolean; /** * Tap handler for the voice icon rendered INSIDE the input. Currently a * placeholder for future voice messaging — no surface implements it yet. @@ -129,6 +137,7 @@ export function LiquidChatComposer({ disabled, placeholder = 'Write here', onPlusPress, + plusDisabled, onVoicePress, bottomPadding = 12, testID, @@ -238,9 +247,10 @@ export function LiquidChatComposer({ }, [surface, value.length, disabled, onSend]); const handlePlusPress = useCallback(() => { - chatLog.info('chat.composer.plus_tap', { surface: surface ?? 'unknown' }); + chatLog.info('chat.composer.plus_tap', { surface: surface ?? 'unknown', plusDisabled }); + if (plusDisabled) return; onPlusPress?.(); - }, [surface, onPlusPress]); + }, [surface, onPlusPress, plusDisabled]); if (useNativeGlass) { return ( @@ -285,6 +295,7 @@ export function LiquidChatComposer({ buttonStyle('glass'), frame({ width: BUTTON_SIZE, height: BUTTON_SIZE }), glassEffectId('plus', namespaceId), + swiftOpacity(plusDisabled ? 0.35 : 1), animation(SEND_SPRING, trimmedHasText), ]} onPress={disabled ? () => {} : handlePlusPress}> @@ -438,8 +449,11 @@ export function LiquidChatComposer({