feat(ai): add input-based pricing tiers
This commit is contained in:
@@ -5,10 +5,12 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a separate opt-in `max` thinking level, including native `xhigh` and `max` support for GPT-5.6 and Anthropic adaptive-thinking effort metadata matching Anthropic's documentation: `max` on all adaptive Claude models, native `xhigh` on Opus 4.7/4.8, Sonnet 5, and Fable 5 only.
|
- Added a separate opt-in `max` thinking level, including native `xhigh` and `max` support for GPT-5.6 and Anthropic adaptive-thinking effort metadata matching Anthropic's documentation: `max` on all adaptive Claude models, native `xhigh` on Opus 4.7/4.8, Sonnet 5, and Fable 5 only.
|
||||||
|
- Added request-wide input-token pricing tiers to model cost metadata and usage cost calculation.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed post-compaction output-token budgeting to ignore stale assistant usage from before the compaction boundary ([#6464](https://github.com/earendil-works/pi/issues/6464)).
|
- Fixed post-compaction output-token budgeting to ignore stale assistant usage from before the compaction boundary ([#6464](https://github.com/earendil-works/pi/issues/6464)).
|
||||||
|
- Fixed GPT-5.6 metadata to keep direct OpenAI requests in the 272K short-context tier while exposing the Codex backend's 372K context window with long-context pricing.
|
||||||
|
|
||||||
## [0.80.5] - 2026-07-09
|
## [0.80.5] - 2026-07-09
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,23 @@ const ANT_LING_RING_THINKING_LEVEL_MAP = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]);
|
const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]);
|
||||||
|
const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000;
|
||||||
|
const OPENAI_GPT_56_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
|
||||||
|
|
||||||
|
function withOpenAiLongContextPricing(cost: Model<Api>["cost"]): Model<Api>["cost"] {
|
||||||
|
return {
|
||||||
|
...cost,
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
inputTokensAbove: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
|
||||||
|
input: cost.input * 2,
|
||||||
|
output: cost.output * 1.5,
|
||||||
|
cacheRead: cost.cacheRead * 2,
|
||||||
|
cacheWrite: cost.cacheWrite * 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([
|
const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([
|
||||||
"gpt-5.1",
|
"gpt-5.1",
|
||||||
@@ -1678,9 +1695,16 @@ async function generateModels() {
|
|||||||
candidate.contextWindow = 272000;
|
candidate.contextWindow = 272000;
|
||||||
candidate.maxTokens = 128000;
|
candidate.maxTokens = 128000;
|
||||||
}
|
}
|
||||||
if (candidate.provider === "openai" && (candidate.id === "gpt-5.4" || candidate.id === "gpt-5.5")) {
|
// Keep direct OpenAI requests in the short-context pricing tier.
|
||||||
candidate.contextWindow = 272000;
|
if (
|
||||||
|
candidate.provider === "openai" &&
|
||||||
|
(candidate.id === "gpt-5.4" || candidate.id === "gpt-5.5" || OPENAI_GPT_56_MODEL_IDS.has(candidate.id))
|
||||||
|
) {
|
||||||
|
candidate.contextWindow = OPENAI_LONG_CONTEXT_INPUT_THRESHOLD;
|
||||||
candidate.maxTokens = 128000;
|
candidate.maxTokens = 128000;
|
||||||
|
if (OPENAI_GPT_56_MODEL_IDS.has(candidate.id)) {
|
||||||
|
candidate.cost = withOpenAiLongContextPricing(candidate.cost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit);
|
// models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit);
|
||||||
// the actual max output is 128000. Also propagates to the derived Azure clone.
|
// the actual max output is 128000. Also propagates to the derived Azure clone.
|
||||||
@@ -1724,8 +1748,8 @@ async function generateModels() {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }),
|
||||||
contextWindow: 1050000,
|
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1736,8 +1760,8 @@ async function generateModels() {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
cost: withOpenAiLongContextPricing({ input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }),
|
||||||
contextWindow: 1050000,
|
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1748,8 +1772,8 @@ async function generateModels() {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 0 },
|
cost: withOpenAiLongContextPricing({ input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 }),
|
||||||
contextWindow: 1050000,
|
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1899,9 +1923,10 @@ async function generateModels() {
|
|||||||
|
|
||||||
// OpenAI Codex (ChatGPT OAuth) models
|
// OpenAI Codex (ChatGPT OAuth) models
|
||||||
// NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases.
|
// NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases.
|
||||||
// Context window is based on observed server limits (400s above ~272k), not marketing numbers.
|
// Older model limits are based on observed server behavior; GPT-5.6 follows Codex's 372k catalog limit.
|
||||||
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||||
const CODEX_CONTEXT = 272000;
|
const CODEX_CONTEXT = 272000;
|
||||||
|
const CODEX_GPT_56_CONTEXT = 372000;
|
||||||
const CODEX_SPARK_CONTEXT = 128000;
|
const CODEX_SPARK_CONTEXT = 128000;
|
||||||
const CODEX_MAX_TOKENS = 128000;
|
const CODEX_MAX_TOKENS = 128000;
|
||||||
const codexModels: Model<"openai-codex-responses">[] = [
|
const codexModels: Model<"openai-codex-responses">[] = [
|
||||||
@@ -1961,8 +1986,8 @@ async function generateModels() {
|
|||||||
baseUrl: CODEX_BASE_URL,
|
baseUrl: CODEX_BASE_URL,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 0 },
|
cost: withOpenAiLongContextPricing({ input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 }),
|
||||||
contextWindow: CODEX_CONTEXT,
|
contextWindow: CODEX_GPT_56_CONTEXT,
|
||||||
maxTokens: CODEX_MAX_TOKENS,
|
maxTokens: CODEX_MAX_TOKENS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1973,8 +1998,8 @@ async function generateModels() {
|
|||||||
baseUrl: CODEX_BASE_URL,
|
baseUrl: CODEX_BASE_URL,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }),
|
||||||
contextWindow: CODEX_CONTEXT,
|
contextWindow: CODEX_GPT_56_CONTEXT,
|
||||||
maxTokens: CODEX_MAX_TOKENS,
|
maxTokens: CODEX_MAX_TOKENS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1985,8 +2010,8 @@ async function generateModels() {
|
|||||||
baseUrl: CODEX_BASE_URL,
|
baseUrl: CODEX_BASE_URL,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
cost: withOpenAiLongContextPricing({ input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }),
|
||||||
contextWindow: CODEX_CONTEXT,
|
contextWindow: CODEX_GPT_56_CONTEXT,
|
||||||
maxTokens: CODEX_MAX_TOKENS,
|
maxTokens: CODEX_MAX_TOKENS,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -2112,11 +2137,14 @@ async function generateModels() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Azure Foundry deploys these with larger context windows than OpenAI's own API,
|
// Azure Foundry deploys these with larger context windows than OpenAI's own short-tier defaults.
|
||||||
// which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs.
|
// See models-sold-directly-by-azure docs.
|
||||||
const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
|
const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
|
||||||
"gpt-5.4": 1050000,
|
"gpt-5.4": 1050000,
|
||||||
"gpt-5.5": 1050000,
|
"gpt-5.5": 1050000,
|
||||||
|
"gpt-5.6-luna": 1050000,
|
||||||
|
"gpt-5.6-sol": 1050000,
|
||||||
|
"gpt-5.6-terra": 1050000,
|
||||||
};
|
};
|
||||||
const azureOpenAiModels: Model<Api>[] = allModels
|
const azureOpenAiModels: Model<Api>[] = allModels
|
||||||
.filter((model) => model.provider === "openai" && model.api === "openai-responses")
|
.filter((model) => model.provider === "openai" && model.api === "openai-responses")
|
||||||
@@ -2125,6 +2153,12 @@ async function generateModels() {
|
|||||||
api: "azure-openai-responses",
|
api: "azure-openai-responses",
|
||||||
provider: "azure-openai-responses",
|
provider: "azure-openai-responses",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
|
cost: {
|
||||||
|
input: model.cost.input,
|
||||||
|
output: model.cost.output,
|
||||||
|
cacheRead: model.cost.cacheRead,
|
||||||
|
cacheWrite: model.cost.cacheWrite,
|
||||||
|
},
|
||||||
contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow,
|
contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow,
|
||||||
}));
|
}));
|
||||||
allModels.push(...azureOpenAiModels);
|
allModels.push(...azureOpenAiModels);
|
||||||
@@ -2179,6 +2213,9 @@ async function generateModels() {
|
|||||||
output += `${indent}\t\toutput: ${model.cost.output},\n`;
|
output += `${indent}\t\toutput: ${model.cost.output},\n`;
|
||||||
output += `${indent}\t\tcacheRead: ${model.cost.cacheRead},\n`;
|
output += `${indent}\t\tcacheRead: ${model.cost.cacheRead},\n`;
|
||||||
output += `${indent}\t\tcacheWrite: ${model.cost.cacheWrite},\n`;
|
output += `${indent}\t\tcacheWrite: ${model.cost.cacheWrite},\n`;
|
||||||
|
if (model.cost.tiers) {
|
||||||
|
output += `${indent}\t\ttiers: ${JSON.stringify(model.cost.tiers)},\n`;
|
||||||
|
}
|
||||||
output += `${indent}\t},\n`;
|
output += `${indent}\t},\n`;
|
||||||
output += `${indent}\tcontextWindow: ${model.contextWindow},\n`;
|
output += `${indent}\tcontextWindow: ${model.contextWindow},\n`;
|
||||||
output += `${indent}\tmaxTokens: ${model.maxTokens},\n`;
|
output += `${indent}\tmaxTokens: ${model.maxTokens},\n`;
|
||||||
|
|||||||
@@ -360,13 +360,17 @@ export async function processResponsesStream<TApi extends Api>(
|
|||||||
output.responseId = response.id;
|
output.responseId = response.id;
|
||||||
}
|
}
|
||||||
if (response?.usage) {
|
if (response?.usage) {
|
||||||
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
|
const inputDetails = response.usage.input_tokens_details as
|
||||||
|
| { cached_tokens?: number; cache_write_tokens?: number }
|
||||||
|
| undefined;
|
||||||
|
const cachedTokens = inputDetails?.cached_tokens || 0;
|
||||||
|
const cacheWriteTokens = inputDetails?.cache_write_tokens || 0;
|
||||||
output.usage = {
|
output.usage = {
|
||||||
// OpenAI includes cached tokens in input_tokens, so subtract to get non-cached input
|
// OpenAI includes cached and cache-write tokens in input_tokens, so subtract both.
|
||||||
input: (response.usage.input_tokens || 0) - cachedTokens,
|
input: Math.max(0, (response.usage.input_tokens || 0) - cachedTokens - cacheWriteTokens),
|
||||||
output: response.usage.output_tokens || 0,
|
output: response.usage.output_tokens || 0,
|
||||||
cacheRead: cachedTokens,
|
cacheRead: cachedTokens,
|
||||||
cacheWrite: 0,
|
cacheWrite: cacheWriteTokens,
|
||||||
reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0,
|
reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0,
|
||||||
totalTokens: response.usage.total_tokens || 0,
|
totalTokens: response.usage.total_tokens || 0,
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AssistantMessageEventStream,
|
AssistantMessageEventStream,
|
||||||
Context,
|
Context,
|
||||||
Model,
|
Model,
|
||||||
|
ModelCostRates,
|
||||||
ModelThinkingLevel,
|
ModelThinkingLevel,
|
||||||
ProviderHeaders,
|
ProviderHeaders,
|
||||||
ProviderStreams,
|
ProviderStreams,
|
||||||
@@ -383,13 +384,23 @@ export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
|
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
|
||||||
|
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
||||||
|
let rates: ModelCostRates = model.cost;
|
||||||
|
let matchedThreshold = -1;
|
||||||
|
for (const tier of model.cost.tiers ?? []) {
|
||||||
|
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
|
||||||
|
rates = tier;
|
||||||
|
matchedThreshold = tier.inputTokensAbove;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Anthropic charges 2x base input for 1h cache writes.
|
// Anthropic charges 2x base input for 1h cache writes.
|
||||||
const longWrite = usage.cacheWrite1h ?? 0;
|
const longWrite = usage.cacheWrite1h ?? 0;
|
||||||
const shortWrite = usage.cacheWrite - longWrite;
|
const shortWrite = usage.cacheWrite - longWrite;
|
||||||
usage.cost.input = (model.cost.input / 1000000) * usage.input;
|
usage.cost.input = (rates.input / 1000000) * usage.input;
|
||||||
usage.cost.output = (model.cost.output / 1000000) * usage.output;
|
usage.cost.output = (rates.output / 1000000) * usage.output;
|
||||||
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
|
usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;
|
||||||
usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000;
|
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
|
||||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||||
return usage.cost;
|
return usage.cost;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,9 +89,10 @@ export const OPENAI_CODEX_MODELS = {
|
|||||||
input: 1,
|
input: 1,
|
||||||
output: 6,
|
output: 6,
|
||||||
cacheRead: 0.1,
|
cacheRead: 0.1,
|
||||||
cacheWrite: 0,
|
cacheWrite: 1.25,
|
||||||
|
tiers: [{"inputTokensAbove":272000,"input":2,"output":9,"cacheRead":0.2,"cacheWrite":2.5}],
|
||||||
},
|
},
|
||||||
contextWindow: 272000,
|
contextWindow: 372000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-codex-responses">,
|
} satisfies Model<"openai-codex-responses">,
|
||||||
"gpt-5.6-sol": {
|
"gpt-5.6-sol": {
|
||||||
@@ -107,9 +108,10 @@ export const OPENAI_CODEX_MODELS = {
|
|||||||
input: 5,
|
input: 5,
|
||||||
output: 30,
|
output: 30,
|
||||||
cacheRead: 0.5,
|
cacheRead: 0.5,
|
||||||
cacheWrite: 0,
|
cacheWrite: 6.25,
|
||||||
|
tiers: [{"inputTokensAbove":272000,"input":10,"output":45,"cacheRead":1,"cacheWrite":12.5}],
|
||||||
},
|
},
|
||||||
contextWindow: 272000,
|
contextWindow: 372000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-codex-responses">,
|
} satisfies Model<"openai-codex-responses">,
|
||||||
"gpt-5.6-terra": {
|
"gpt-5.6-terra": {
|
||||||
@@ -125,9 +127,10 @@ export const OPENAI_CODEX_MODELS = {
|
|||||||
input: 2.5,
|
input: 2.5,
|
||||||
output: 15,
|
output: 15,
|
||||||
cacheRead: 0.25,
|
cacheRead: 0.25,
|
||||||
cacheWrite: 0,
|
cacheWrite: 3.125,
|
||||||
|
tiers: [{"inputTokensAbove":272000,"input":5,"output":22.5,"cacheRead":0.5,"cacheWrite":6.25}],
|
||||||
},
|
},
|
||||||
contextWindow: 272000,
|
contextWindow: 372000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-codex-responses">,
|
} satisfies Model<"openai-codex-responses">,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -620,8 +620,9 @@ export const OPENAI_MODELS = {
|
|||||||
output: 6,
|
output: 6,
|
||||||
cacheRead: 0.1,
|
cacheRead: 0.1,
|
||||||
cacheWrite: 1.25,
|
cacheWrite: 1.25,
|
||||||
|
tiers: [{"inputTokensAbove":272000,"input":2,"output":9,"cacheRead":0.2,"cacheWrite":2.5}],
|
||||||
},
|
},
|
||||||
contextWindow: 1050000,
|
contextWindow: 272000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-responses">,
|
} satisfies Model<"openai-responses">,
|
||||||
"gpt-5.6-sol": {
|
"gpt-5.6-sol": {
|
||||||
@@ -638,8 +639,9 @@ export const OPENAI_MODELS = {
|
|||||||
output: 30,
|
output: 30,
|
||||||
cacheRead: 0.5,
|
cacheRead: 0.5,
|
||||||
cacheWrite: 6.25,
|
cacheWrite: 6.25,
|
||||||
|
tiers: [{"inputTokensAbove":272000,"input":10,"output":45,"cacheRead":1,"cacheWrite":12.5}],
|
||||||
},
|
},
|
||||||
contextWindow: 1050000,
|
contextWindow: 272000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-responses">,
|
} satisfies Model<"openai-responses">,
|
||||||
"gpt-5.6-terra": {
|
"gpt-5.6-terra": {
|
||||||
@@ -656,8 +658,9 @@ export const OPENAI_MODELS = {
|
|||||||
output: 15,
|
output: 15,
|
||||||
cacheRead: 0.25,
|
cacheRead: 0.25,
|
||||||
cacheWrite: 3.125,
|
cacheWrite: 3.125,
|
||||||
|
tiers: [{"inputTokensAbove":272000,"input":5,"output":22.5,"cacheRead":0.5,"cacheWrite":6.25}],
|
||||||
},
|
},
|
||||||
contextWindow: 1050000,
|
contextWindow: 272000,
|
||||||
maxTokens: 128000,
|
maxTokens: 128000,
|
||||||
} satisfies Model<"openai-responses">,
|
} satisfies Model<"openai-responses">,
|
||||||
"o1": {
|
"o1": {
|
||||||
|
|||||||
@@ -662,6 +662,23 @@ export interface VercelGatewayRouting {
|
|||||||
order?: string[];
|
order?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelCostRates {
|
||||||
|
input: number; // $/million tokens
|
||||||
|
output: number; // $/million tokens
|
||||||
|
cacheRead: number; // $/million tokens
|
||||||
|
cacheWrite: number; // $/million tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCostTier extends ModelCostRates {
|
||||||
|
/** Use this tier for requests whose total input usage exceeds this token count. */
|
||||||
|
inputTokensAbove: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCost extends ModelCostRates {
|
||||||
|
/** Request-wide pricing tiers. The highest matching input threshold applies to the full request. */
|
||||||
|
tiers?: ModelCostTier[];
|
||||||
|
}
|
||||||
|
|
||||||
// Model interface for the unified model system
|
// Model interface for the unified model system
|
||||||
export interface Model<TApi extends Api> {
|
export interface Model<TApi extends Api> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -676,12 +693,7 @@ export interface Model<TApi extends Api> {
|
|||||||
*/
|
*/
|
||||||
thinkingLevelMap?: ThinkingLevelMap;
|
thinkingLevelMap?: ThinkingLevelMap;
|
||||||
input: ("text" | "image")[];
|
input: ("text" | "image")[];
|
||||||
cost: {
|
cost: ModelCost;
|
||||||
input: number; // $/million tokens
|
|
||||||
output: number; // $/million tokens
|
|
||||||
cacheRead: number; // $/million tokens
|
|
||||||
cacheWrite: number; // $/million tokens
|
|
||||||
};
|
|
||||||
contextWindow: number;
|
contextWindow: number;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||||
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts";
|
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts";
|
||||||
import { createModels, hasApi, type Provider } from "../src/models.ts";
|
import { calculateCost, createModels, hasApi, type Provider } from "../src/models.ts";
|
||||||
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions } from "../src/types.ts";
|
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions, Usage } from "../src/types.ts";
|
||||||
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
||||||
|
|
||||||
function testModel(provider: string, id: string): Model<Api> {
|
function testModel(provider: string, id: string): Model<Api> {
|
||||||
@@ -106,6 +106,42 @@ function testOAuth(overrides?: Partial<OAuthAuth>): OAuthAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Models runtime", () => {
|
describe("Models runtime", () => {
|
||||||
|
it("applies request-wide pricing tiers above the configured input threshold", () => {
|
||||||
|
const model = testModel("openai", "gpt-5.6-sol");
|
||||||
|
model.cost = {
|
||||||
|
input: 5,
|
||||||
|
output: 30,
|
||||||
|
cacheRead: 0.5,
|
||||||
|
cacheWrite: 6.25,
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
inputTokensAbove: 272000,
|
||||||
|
input: 10,
|
||||||
|
output: 45,
|
||||||
|
cacheRead: 1,
|
||||||
|
cacheWrite: 12.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const createUsage = (cacheWrite: number): Usage => ({
|
||||||
|
input: 200000,
|
||||||
|
output: 100000,
|
||||||
|
cacheRead: 72000,
|
||||||
|
cacheWrite,
|
||||||
|
totalTokens: 372000 + cacheWrite,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const short = calculateCost(model, createUsage(0));
|
||||||
|
expect(short).toMatchObject({ input: 1, output: 3, cacheRead: 0.036, cacheWrite: 0 });
|
||||||
|
|
||||||
|
const long = calculateCost(model, createUsage(1));
|
||||||
|
expect(long.input).toBe(2);
|
||||||
|
expect(long.output).toBe(4.5);
|
||||||
|
expect(long.cacheRead).toBe(0.072);
|
||||||
|
expect(long.cacheWrite).toBe(0.0000125);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers, replaces, and deletes providers", () => {
|
it("registers, replaces, and deletes providers", () => {
|
||||||
const models = createModels();
|
const models = createModels();
|
||||||
models.setProvider(testProvider({ id: "p1" }));
|
models.setProvider(testProvider({ id: "p1" }));
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ async function* createCompletedEvents(): AsyncIterable<ResponseStreamEvent> {
|
|||||||
input_tokens: 20,
|
input_tokens: 20,
|
||||||
output_tokens: 7,
|
output_tokens: 7,
|
||||||
total_tokens: 27,
|
total_tokens: 27,
|
||||||
input_tokens_details: { cached_tokens: 2 },
|
input_tokens_details: { cached_tokens: 2, cache_write_tokens: 3 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as ResponseStreamEvent;
|
} as unknown as ResponseStreamEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function* createIncompleteEvents(): AsyncIterable<ResponseStreamEvent> {
|
async function* createIncompleteEvents(): AsyncIterable<ResponseStreamEvent> {
|
||||||
@@ -195,10 +195,10 @@ describe("OpenAI Responses terminal event handling", () => {
|
|||||||
expect(output.responseId).toBe("resp_completed");
|
expect(output.responseId).toBe("resp_completed");
|
||||||
expect(output.stopReason).toBe("stop");
|
expect(output.stopReason).toBe("stop");
|
||||||
expect(output.usage).toMatchObject({
|
expect(output.usage).toMatchObject({
|
||||||
input: 18,
|
input: 15,
|
||||||
output: 7,
|
output: 7,
|
||||||
cacheRead: 2,
|
cacheRead: 2,
|
||||||
cacheWrite: 0,
|
cacheWrite: 3,
|
||||||
totalTokens: 27,
|
totalTokens: 27,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`.
|
- Added the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`.
|
||||||
|
- Added request-wide input-token pricing tiers to custom model costs in `models.json`, `modelOverrides`, and extension-registered providers.
|
||||||
|
|
||||||
## [0.80.5] - 2026-07-09
|
## [0.80.5] - 2026-07-09
|
||||||
|
|
||||||
|
|||||||
@@ -205,9 +205,31 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
|
|||||||
| `input` | No | `["text"]` | Input types: `["text"]` or `["text", "image"]` |
|
| `input` | No | `["text"]` | Input types: `["text"]` or `["text", "image"]` |
|
||||||
| `contextWindow` | No | `128000` | Context window size in tokens |
|
| `contextWindow` | No | `128000` | Context window size in tokens |
|
||||||
| `maxTokens` | No | `16384` | Maximum output tokens |
|
| `maxTokens` | No | `16384` | Maximum output tokens |
|
||||||
| `cost` | No | all zeros | `{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}` (per million tokens) |
|
| `cost` | No | all zeros | Per-million-token rates with optional request-wide input pricing tiers |
|
||||||
| `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. |
|
| `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. |
|
||||||
|
|
||||||
|
A cost tier supplies a complete alternate rate set and applies to the full request when total input usage (`input + cacheRead + cacheWrite`) exceeds `inputTokensAbove`. When multiple tiers match, the highest threshold wins.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cost": {
|
||||||
|
"input": 5,
|
||||||
|
"output": 30,
|
||||||
|
"cacheRead": 0.5,
|
||||||
|
"cacheWrite": 6.25,
|
||||||
|
"tiers": [
|
||||||
|
{
|
||||||
|
"inputTokensAbove": 272000,
|
||||||
|
"input": 10,
|
||||||
|
"output": 45,
|
||||||
|
"cacheRead": 1,
|
||||||
|
"cacheWrite": 12.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Current behavior:
|
Current behavior:
|
||||||
- `/model`, `--list-models`, and the interactive footer display entries by model `id`.
|
- `/model`, `--list-models`, and the interactive footer display entries by model `id`.
|
||||||
- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id.
|
- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id.
|
||||||
@@ -317,6 +339,24 @@ Use `modelOverrides` to customize built-in models and matching extension-registe
|
|||||||
|
|
||||||
`modelOverrides` supports these fields per model: `name`, `reasoning`, `thinkingLevelMap`, `input`, `cost` (partial), `contextWindow`, `maxTokens`, `headers`, `compat`.
|
`modelOverrides` supports these fields per model: `name`, `reasoning`, `thinkingLevelMap`, `input`, `cost` (partial), `contextWindow`, `maxTokens`, `headers`, `compat`.
|
||||||
|
|
||||||
|
Direct OpenAI GPT-5.6 Sol, Terra, and Luna default to a `272000` context window so requests remain within OpenAI's short-context pricing tier. To opt into OpenAI's 1.05M context window, increase it for each model you use:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"openai": {
|
||||||
|
"modelOverrides": {
|
||||||
|
"gpt-5.6-sol": {
|
||||||
|
"contextWindow": 1050000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The override preserves the built-in pricing metadata. Requests with more than 272K total input tokens use GPT-5.6's long-context rates for the entire request. Apply the same override to `gpt-5.6-terra` or `gpt-5.6-luna` when needed.
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- `modelOverrides` are applied to built-in provider models and matching extension-registered provider models.
|
- `modelOverrides` are applied to built-in provider models and matching extension-registered provider models.
|
||||||
- Unknown model IDs are ignored.
|
- Unknown model IDs are ignored.
|
||||||
|
|||||||
@@ -1451,8 +1451,8 @@ export interface ProviderModelConfig {
|
|||||||
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
|
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
|
||||||
/** Supported input types. */
|
/** Supported input types. */
|
||||||
input: ("text" | "image")[];
|
input: ("text" | "image")[];
|
||||||
/** Cost per token (for tracking, can be 0). */
|
/** Per-million-token cost rates and optional request-wide input pricing tiers. */
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
cost: Model<Api>["cost"];
|
||||||
/** Maximum context window size in tokens. */
|
/** Maximum context window size in tokens. */
|
||||||
contextWindow: number;
|
contextWindow: number;
|
||||||
/** Maximum output tokens. */
|
/** Maximum output tokens. */
|
||||||
|
|||||||
@@ -156,6 +156,21 @@ const ProviderCompatSchema = Type.Union([
|
|||||||
AnthropicMessagesCompatSchema,
|
AnthropicMessagesCompatSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const ModelCostRatesSchema = {
|
||||||
|
input: Type.Number(),
|
||||||
|
output: Type.Number(),
|
||||||
|
cacheRead: Type.Number(),
|
||||||
|
cacheWrite: Type.Number(),
|
||||||
|
};
|
||||||
|
const ModelCostTierSchema = Type.Object({
|
||||||
|
inputTokensAbove: Type.Number(),
|
||||||
|
...ModelCostRatesSchema,
|
||||||
|
});
|
||||||
|
const ModelCostSchema = Type.Object({
|
||||||
|
...ModelCostRatesSchema,
|
||||||
|
tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
|
||||||
|
});
|
||||||
|
|
||||||
// Schema for custom model definition
|
// Schema for custom model definition
|
||||||
// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.)
|
// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.)
|
||||||
const ModelDefinitionSchema = Type.Object({
|
const ModelDefinitionSchema = Type.Object({
|
||||||
@@ -166,14 +181,7 @@ const ModelDefinitionSchema = Type.Object({
|
|||||||
reasoning: Type.Optional(Type.Boolean()),
|
reasoning: Type.Optional(Type.Boolean()),
|
||||||
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
|
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
|
||||||
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
|
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
|
||||||
cost: Type.Optional(
|
cost: Type.Optional(ModelCostSchema),
|
||||||
Type.Object({
|
|
||||||
input: Type.Number(),
|
|
||||||
output: Type.Number(),
|
|
||||||
cacheRead: Type.Number(),
|
|
||||||
cacheWrite: Type.Number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
contextWindow: Type.Optional(Type.Number()),
|
contextWindow: Type.Optional(Type.Number()),
|
||||||
maxTokens: Type.Optional(Type.Number()),
|
maxTokens: Type.Optional(Type.Number()),
|
||||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||||
@@ -192,6 +200,7 @@ const ModelOverrideSchema = Type.Object({
|
|||||||
output: Type.Optional(Type.Number()),
|
output: Type.Optional(Type.Number()),
|
||||||
cacheRead: Type.Optional(Type.Number()),
|
cacheRead: Type.Optional(Type.Number()),
|
||||||
cacheWrite: Type.Optional(Type.Number()),
|
cacheWrite: Type.Optional(Type.Number()),
|
||||||
|
tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
contextWindow: Type.Optional(Type.Number()),
|
contextWindow: Type.Optional(Type.Number()),
|
||||||
@@ -335,6 +344,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
|
|||||||
output: override.cost.output ?? model.cost.output,
|
output: override.cost.output ?? model.cost.output,
|
||||||
cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
|
cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
|
||||||
cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
|
cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
|
||||||
|
tiers: override.cost.tiers ?? model.cost.tiers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,7 +1009,7 @@ export interface ProviderConfigInput {
|
|||||||
reasoning: boolean;
|
reasoning: boolean;
|
||||||
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
|
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
|
||||||
input: ("text" | "image")[];
|
input: ("text" | "image")[];
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
cost: Model<Api>["cost"];
|
||||||
contextWindow: number;
|
contextWindow: number;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|||||||
@@ -49,7 +49,21 @@ describe("ExtensionRunner", () => {
|
|||||||
name: "Instant Model",
|
name: "Instant Model",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
cost: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 0.1,
|
||||||
|
cacheWrite: 1.25,
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
inputTokensAbove: 272000,
|
||||||
|
input: 2,
|
||||||
|
output: 3,
|
||||||
|
cacheRead: 0.2,
|
||||||
|
cacheWrite: 2.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
},
|
},
|
||||||
@@ -859,7 +873,15 @@ describe("ExtensionRunner", () => {
|
|||||||
|
|
||||||
runtime.registerProvider("instant-provider", providerModelConfig);
|
runtime.registerProvider("instant-provider", providerModelConfig);
|
||||||
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
||||||
expect(modelRegistry.find("instant-provider", "instant-model")).toBeDefined();
|
expect(modelRegistry.find("instant-provider", "instant-model")?.cost.tiers).toEqual([
|
||||||
|
{
|
||||||
|
inputTokensAbove: 272000,
|
||||||
|
input: 2,
|
||||||
|
output: 3,
|
||||||
|
cacheRead: 0.2,
|
||||||
|
cacheWrite: 2.5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
runtime.unregisterProvider("instant-provider");
|
runtime.unregisterProvider("instant-provider");
|
||||||
expect(modelRegistry.find("instant-provider", "instant-model")).toBeUndefined();
|
expect(modelRegistry.find("instant-provider", "instant-model")).toBeUndefined();
|
||||||
|
|||||||
Reference in New Issue
Block a user