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. */}
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({