@oxygen-agent/cli 1.1013.93 → 1.1014.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +190 -70
- package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +115 -0
- package/node_modules/@oxygen/shared/dist/agent-sessions.js +133 -0
- package/node_modules/@oxygen/shared/dist/app-storage-maintenance.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/app-storage-maintenance.js +16 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -4
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +72 -0
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +35 -0
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +16 -4
- package/node_modules/@oxygen/shared/dist/copilot-access.d.ts +33 -0
- package/node_modules/@oxygen/shared/dist/copilot-access.js +33 -0
- package/node_modules/@oxygen/shared/dist/copilot-questions.d.ts +91 -0
- package/node_modules/@oxygen/shared/dist/copilot-questions.js +246 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +28 -18
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +96 -45
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +37 -9
- package/node_modules/@oxygen/shared/dist/object-storage.js +9 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +5 -1
- package/node_modules/@oxygen/shared/dist/sequences.js +91 -0
- package/node_modules/@oxygen/shared/dist/tool-call-markup.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/tool-call-markup.js +213 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +5 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export declare const COPILOT_ASK_USER_TOOL_NAME = "ask_user";
|
|
2
|
+
export declare const COPILOT_QUESTION_LIMITS: {
|
|
3
|
+
readonly maxQuestions: 4;
|
|
4
|
+
readonly minOptions: 2;
|
|
5
|
+
readonly maxOptions: 4;
|
|
6
|
+
readonly questionChars: 300;
|
|
7
|
+
readonly headerChars: 24;
|
|
8
|
+
readonly labelChars: 80;
|
|
9
|
+
readonly descriptionChars: 200;
|
|
10
|
+
/** What the user may type into "Other". */
|
|
11
|
+
readonly otherAnswerChars: 1000;
|
|
12
|
+
};
|
|
13
|
+
export type CopilotQuestionOption = {
|
|
14
|
+
label: string;
|
|
15
|
+
description: string | null;
|
|
16
|
+
recommended: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type CopilotQuestion = {
|
|
19
|
+
question: string;
|
|
20
|
+
/** A short chip label, e.g. "Send time". */
|
|
21
|
+
header: string;
|
|
22
|
+
multiSelect: boolean;
|
|
23
|
+
options: CopilotQuestionOption[];
|
|
24
|
+
};
|
|
25
|
+
/** The question a session is waiting on, as every surface reads it. */
|
|
26
|
+
export type CopilotPendingQuestion = {
|
|
27
|
+
turnId: string;
|
|
28
|
+
callId: string | null;
|
|
29
|
+
questions: CopilotQuestion[];
|
|
30
|
+
};
|
|
31
|
+
/** What the user picked for one question, plus anything they typed into "Other". */
|
|
32
|
+
export type CopilotQuestionAnswer = {
|
|
33
|
+
selected: string[];
|
|
34
|
+
other: string | null;
|
|
35
|
+
};
|
|
36
|
+
/** The tool's input schema, as the model reads it. */
|
|
37
|
+
export declare const COPILOT_ASK_USER_INPUT_SCHEMA: Record<string, unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Validate the model's questions, or a stored copy of them. Strict about what
|
|
40
|
+
* would make the card unanswerable (no question, fewer than two real options,
|
|
41
|
+
* duplicates) and forgiving about the rest: an over-long label is shortened, an
|
|
42
|
+
* "Other" option is dropped because the card adds one, and a second
|
|
43
|
+
* `recommended` loses its mark. Accepts `multi_select` (the tool's input) and
|
|
44
|
+
* `multiSelect` (the stored shape) alike.
|
|
45
|
+
*/
|
|
46
|
+
export declare function normalizeCopilotQuestions(raw: unknown): {
|
|
47
|
+
ok: true;
|
|
48
|
+
questions: CopilotQuestion[];
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
message: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The questions as the turn's reply: what the CLI and MCP print, what the
|
|
55
|
+
* session keeps, and what the model reads back on the next turn, which replays
|
|
56
|
+
* earlier turns as plain text.
|
|
57
|
+
*/
|
|
58
|
+
export declare function renderCopilotQuestionsText(questions: readonly CopilotQuestion[]): string;
|
|
59
|
+
/**
|
|
60
|
+
* The user's reply to a question card: one line per question, `header: answer`,
|
|
61
|
+
* so the model can match every answer to what it asked. Picks follow the order
|
|
62
|
+
* the options were offered in, not the order they were clicked. "Other" text
|
|
63
|
+
* stands on its own; it is the user's own wording.
|
|
64
|
+
*/
|
|
65
|
+
export declare function formatCopilotQuestionAnswers(questions: readonly CopilotQuestion[], answers: readonly CopilotQuestionAnswer[]): string;
|
|
66
|
+
/** Is every question answered, so the card can be sent? */
|
|
67
|
+
export declare function copilotQuestionAnswersComplete(questions: readonly CopilotQuestion[], answers: readonly CopilotQuestionAnswer[]): boolean;
|
|
68
|
+
type ProjectionTurn = {
|
|
69
|
+
id: string;
|
|
70
|
+
status: string;
|
|
71
|
+
turnIndex: number;
|
|
72
|
+
};
|
|
73
|
+
type ProjectionEvent = {
|
|
74
|
+
turnId: string | null;
|
|
75
|
+
kind: string;
|
|
76
|
+
payload: Record<string, unknown>;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The question the session is waiting on, or null.
|
|
80
|
+
*
|
|
81
|
+
* Only the NEWEST turn can be waiting, and only when it completed by asking: a
|
|
82
|
+
* newer turn means the user already replied (by the card or by typing), and a
|
|
83
|
+
* turn that failed or was cancelled after asking is not waiting on anything.
|
|
84
|
+
* Stored questions are re-validated, so a malformed row renders nothing rather
|
|
85
|
+
* than a card that cannot be answered.
|
|
86
|
+
*/
|
|
87
|
+
export declare function projectCopilotPendingQuestion(input: {
|
|
88
|
+
turns: readonly ProjectionTurn[];
|
|
89
|
+
events: readonly ProjectionEvent[];
|
|
90
|
+
}): CopilotPendingQuestion | null;
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The Copilot's questions to the person it works for.
|
|
2
|
+
//
|
|
3
|
+
// When the Copilot needs a decision only the user can make, it calls `ask_user`
|
|
4
|
+
// with a few multiple-choice questions instead of burying them at the end of a
|
|
5
|
+
// reply (Tim, 2026-10-10, after Claude Code's AskUserQuestion). The call ENDS the
|
|
6
|
+
// turn; the web shows the questions as a card above the composer, with an
|
|
7
|
+
// automatic "Other" answer; the user's choice arrives as their next message.
|
|
8
|
+
//
|
|
9
|
+
// Ending the turn rather than parking it is the whole design. A parked turn
|
|
10
|
+
// blocks the session (one live turn per session), runs a wall clock while the
|
|
11
|
+
// human thinks, and needs its own resume route on every surface. A question
|
|
12
|
+
// that ends the turn needs none of that: the CLI and MCP answer it the way they
|
|
13
|
+
// send any message, an ignored card is simply superseded by whatever the user
|
|
14
|
+
// types next, and the model reads the question back from the turn's reply,
|
|
15
|
+
// because prior turns replay as user/assistant text.
|
|
16
|
+
//
|
|
17
|
+
// One owner for the contract because four readers depend on it: the tool that
|
|
18
|
+
// validates the model's input, the reply text the CLI and MCP print, the session
|
|
19
|
+
// read that projects the pending question, and the web card that answers it.
|
|
20
|
+
export const COPILOT_ASK_USER_TOOL_NAME = "ask_user";
|
|
21
|
+
export const COPILOT_QUESTION_LIMITS = {
|
|
22
|
+
maxQuestions: 4,
|
|
23
|
+
minOptions: 2,
|
|
24
|
+
maxOptions: 4,
|
|
25
|
+
questionChars: 300,
|
|
26
|
+
headerChars: 24,
|
|
27
|
+
labelChars: 80,
|
|
28
|
+
descriptionChars: 200,
|
|
29
|
+
/** What the user may type into "Other". */
|
|
30
|
+
otherAnswerChars: 1_000,
|
|
31
|
+
};
|
|
32
|
+
/** The tool's input schema, as the model reads it. */
|
|
33
|
+
export const COPILOT_ASK_USER_INPUT_SCHEMA = {
|
|
34
|
+
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
properties: {
|
|
37
|
+
questions: {
|
|
38
|
+
type: "array",
|
|
39
|
+
minItems: 1,
|
|
40
|
+
maxItems: COPILOT_QUESTION_LIMITS.maxQuestions,
|
|
41
|
+
items: {
|
|
42
|
+
type: "object",
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
properties: {
|
|
45
|
+
question: {
|
|
46
|
+
type: "string",
|
|
47
|
+
minLength: 1,
|
|
48
|
+
maxLength: COPILOT_QUESTION_LIMITS.questionChars,
|
|
49
|
+
description: "The complete question, ending with a question mark.",
|
|
50
|
+
},
|
|
51
|
+
header: {
|
|
52
|
+
type: "string",
|
|
53
|
+
minLength: 1,
|
|
54
|
+
maxLength: COPILOT_QUESTION_LIMITS.headerChars,
|
|
55
|
+
description: "A one-to-three word label for the question, e.g. \"Send time\".",
|
|
56
|
+
},
|
|
57
|
+
multi_select: {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
description: "True when the user may pick more than one option.",
|
|
60
|
+
},
|
|
61
|
+
options: {
|
|
62
|
+
type: "array",
|
|
63
|
+
minItems: COPILOT_QUESTION_LIMITS.minOptions,
|
|
64
|
+
maxItems: COPILOT_QUESTION_LIMITS.maxOptions,
|
|
65
|
+
items: {
|
|
66
|
+
type: "object",
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
label: {
|
|
70
|
+
type: "string",
|
|
71
|
+
minLength: 1,
|
|
72
|
+
maxLength: COPILOT_QUESTION_LIMITS.labelChars,
|
|
73
|
+
description: "The choice itself, in one to five words.",
|
|
74
|
+
},
|
|
75
|
+
description: {
|
|
76
|
+
type: "string",
|
|
77
|
+
maxLength: COPILOT_QUESTION_LIMITS.descriptionChars,
|
|
78
|
+
description: "What picking it means, in one short line.",
|
|
79
|
+
},
|
|
80
|
+
recommended: {
|
|
81
|
+
type: "boolean",
|
|
82
|
+
description: "Your recommendation; at most one per question, listed first.",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
required: ["label"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ["question", "header", "options"],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: ["questions"],
|
|
94
|
+
};
|
|
95
|
+
function isRecord(value) {
|
|
96
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
97
|
+
}
|
|
98
|
+
/** Collapse whitespace and cap at `max` characters, marking a cut with an ellipsis. */
|
|
99
|
+
function clean(value, max) {
|
|
100
|
+
if (typeof value !== "string")
|
|
101
|
+
return "";
|
|
102
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
103
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`;
|
|
104
|
+
}
|
|
105
|
+
/** The automatic answer; an option the model spells this way is redundant. */
|
|
106
|
+
const OTHER_LABEL = /^other\b/i;
|
|
107
|
+
/**
|
|
108
|
+
* Validate the model's questions, or a stored copy of them. Strict about what
|
|
109
|
+
* would make the card unanswerable (no question, fewer than two real options,
|
|
110
|
+
* duplicates) and forgiving about the rest: an over-long label is shortened, an
|
|
111
|
+
* "Other" option is dropped because the card adds one, and a second
|
|
112
|
+
* `recommended` loses its mark. Accepts `multi_select` (the tool's input) and
|
|
113
|
+
* `multiSelect` (the stored shape) alike.
|
|
114
|
+
*/
|
|
115
|
+
export function normalizeCopilotQuestions(raw) {
|
|
116
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
117
|
+
return { ok: false, message: "questions must be a non-empty array." };
|
|
118
|
+
}
|
|
119
|
+
if (raw.length > COPILOT_QUESTION_LIMITS.maxQuestions) {
|
|
120
|
+
return { ok: false, message: `Ask at most ${COPILOT_QUESTION_LIMITS.maxQuestions} questions at once.` };
|
|
121
|
+
}
|
|
122
|
+
const questions = [];
|
|
123
|
+
const seenQuestions = new Set();
|
|
124
|
+
for (const [index, entry] of raw.entries()) {
|
|
125
|
+
const number = index + 1;
|
|
126
|
+
if (!isRecord(entry))
|
|
127
|
+
return { ok: false, message: `Question ${number} must be an object.` };
|
|
128
|
+
const question = clean(entry.question, COPILOT_QUESTION_LIMITS.questionChars);
|
|
129
|
+
if (!question)
|
|
130
|
+
return { ok: false, message: `Question ${number} has no question text.` };
|
|
131
|
+
if (seenQuestions.has(question.toLowerCase())) {
|
|
132
|
+
return { ok: false, message: `Question ${number} repeats an earlier question.` };
|
|
133
|
+
}
|
|
134
|
+
seenQuestions.add(question.toLowerCase());
|
|
135
|
+
if (!Array.isArray(entry.options)) {
|
|
136
|
+
return { ok: false, message: `Question ${number} needs an options array.` };
|
|
137
|
+
}
|
|
138
|
+
const options = [];
|
|
139
|
+
const seenLabels = new Set();
|
|
140
|
+
let recommended = false;
|
|
141
|
+
for (const rawOption of entry.options) {
|
|
142
|
+
const option = isRecord(rawOption) ? rawOption : { label: rawOption };
|
|
143
|
+
const label = clean(option.label, COPILOT_QUESTION_LIMITS.labelChars);
|
|
144
|
+
if (!label || OTHER_LABEL.test(label))
|
|
145
|
+
continue;
|
|
146
|
+
if (seenLabels.has(label.toLowerCase())) {
|
|
147
|
+
return { ok: false, message: `Question ${number} lists "${label}" twice.` };
|
|
148
|
+
}
|
|
149
|
+
seenLabels.add(label.toLowerCase());
|
|
150
|
+
const description = clean(option.description, COPILOT_QUESTION_LIMITS.descriptionChars);
|
|
151
|
+
const isRecommended = option.recommended === true && !recommended;
|
|
152
|
+
recommended ||= isRecommended;
|
|
153
|
+
options.push({ label, description: description || null, recommended: isRecommended });
|
|
154
|
+
}
|
|
155
|
+
if (options.length < COPILOT_QUESTION_LIMITS.minOptions) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
message: `Question ${number} needs at least ${COPILOT_QUESTION_LIMITS.minOptions} real options; "Other" is added for the user automatically.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (options.length > COPILOT_QUESTION_LIMITS.maxOptions) {
|
|
162
|
+
return { ok: false, message: `Question ${number} offers more than ${COPILOT_QUESTION_LIMITS.maxOptions} options.` };
|
|
163
|
+
}
|
|
164
|
+
questions.push({
|
|
165
|
+
question,
|
|
166
|
+
header: clean(entry.header, COPILOT_QUESTION_LIMITS.headerChars) || `Question ${number}`,
|
|
167
|
+
multiSelect: entry.multi_select === true || entry.multiSelect === true,
|
|
168
|
+
options,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, questions };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The questions as the turn's reply: what the CLI and MCP print, what the
|
|
175
|
+
* session keeps, and what the model reads back on the next turn, which replays
|
|
176
|
+
* earlier turns as plain text.
|
|
177
|
+
*/
|
|
178
|
+
export function renderCopilotQuestionsText(questions) {
|
|
179
|
+
const blocks = questions.map((question) => {
|
|
180
|
+
const lines = [`**${question.question}**${question.multiSelect ? " (choose any)" : ""}`];
|
|
181
|
+
for (const option of question.options) {
|
|
182
|
+
const mark = option.recommended ? " (recommended)" : "";
|
|
183
|
+
lines.push(`- ${option.label}${mark}${option.description ? `: ${option.description}` : ""}`);
|
|
184
|
+
}
|
|
185
|
+
return lines.join("\n");
|
|
186
|
+
});
|
|
187
|
+
return [...blocks, "Pick an option, or answer in your own words."].join("\n\n");
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The user's reply to a question card: one line per question, `header: answer`,
|
|
191
|
+
* so the model can match every answer to what it asked. Picks follow the order
|
|
192
|
+
* the options were offered in, not the order they were clicked. "Other" text
|
|
193
|
+
* stands on its own; it is the user's own wording.
|
|
194
|
+
*/
|
|
195
|
+
export function formatCopilotQuestionAnswers(questions, answers) {
|
|
196
|
+
return questions
|
|
197
|
+
.map((question, index) => {
|
|
198
|
+
const answer = answers[index];
|
|
199
|
+
const selected = new Set(answer?.selected ?? []);
|
|
200
|
+
const picked = question.options.map((option) => option.label).filter((label) => selected.has(label));
|
|
201
|
+
const other = answer?.other?.replace(/\s+/g, " ").trim().slice(0, COPILOT_QUESTION_LIMITS.otherAnswerChars) ?? "";
|
|
202
|
+
const parts = question.multiSelect ? [...picked, ...(other ? [other] : [])] : [other || picked[0] || ""];
|
|
203
|
+
return `${question.header}: ${parts.filter(Boolean).join(", ")}`;
|
|
204
|
+
})
|
|
205
|
+
.join("\n");
|
|
206
|
+
}
|
|
207
|
+
/** Is every question answered, so the card can be sent? */
|
|
208
|
+
export function copilotQuestionAnswersComplete(questions, answers) {
|
|
209
|
+
return questions.every((_, index) => {
|
|
210
|
+
const answer = answers[index];
|
|
211
|
+
return Boolean(answer && (answer.selected.length > 0 || answer.other?.trim()));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The question the session is waiting on, or null.
|
|
216
|
+
*
|
|
217
|
+
* Only the NEWEST turn can be waiting, and only when it completed by asking: a
|
|
218
|
+
* newer turn means the user already replied (by the card or by typing), and a
|
|
219
|
+
* turn that failed or was cancelled after asking is not waiting on anything.
|
|
220
|
+
* Stored questions are re-validated, so a malformed row renders nothing rather
|
|
221
|
+
* than a card that cannot be answered.
|
|
222
|
+
*/
|
|
223
|
+
export function projectCopilotPendingQuestion(input) {
|
|
224
|
+
const newest = input.turns.reduce((latest, turn) => (latest === null || turn.turnIndex > latest.turnIndex ? turn : latest), null);
|
|
225
|
+
if (!newest || newest.status !== "completed")
|
|
226
|
+
return null;
|
|
227
|
+
for (let index = input.events.length - 1; index >= 0; index -= 1) {
|
|
228
|
+
const event = input.events[index];
|
|
229
|
+
if (!event || event.turnId !== newest.id || event.kind !== "tool_call_finished")
|
|
230
|
+
continue;
|
|
231
|
+
if (event.payload.tool !== COPILOT_ASK_USER_TOOL_NAME)
|
|
232
|
+
continue;
|
|
233
|
+
const result = event.payload.result;
|
|
234
|
+
if (!isRecord(result) || result.ok === false)
|
|
235
|
+
continue;
|
|
236
|
+
const normalized = normalizeCopilotQuestions(result.questions);
|
|
237
|
+
if (!normalized.ok)
|
|
238
|
+
return null;
|
|
239
|
+
return {
|
|
240
|
+
turnId: newest.id,
|
|
241
|
+
callId: typeof event.payload.call_id === "string" ? event.payload.call_id : null,
|
|
242
|
+
questions: normalized.questions,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
@@ -17,16 +17,15 @@
|
|
|
17
17
|
* overrides (`OXYGEN_COPILOT_MODEL_*` / `OXYGEN_COPILOT_FALLBACK_*`) applied
|
|
18
18
|
* here by `resolveHostedAiModel`.
|
|
19
19
|
*
|
|
20
|
-
* Kept as a leaf module (mirrors ./pricing-sheet.ts):
|
|
21
|
-
*
|
|
20
|
+
* Kept as a leaf module (mirrors ./pricing-sheet.ts): it imports only the
|
|
21
|
+
* dependency-free Copilot access leaf, never the shared barrel, so any package
|
|
22
|
+
* can re-source the catalog without dragging the barrel in.
|
|
22
23
|
*/
|
|
23
24
|
export type HostedAiLevel = "low" | "medium" | "high";
|
|
24
25
|
export type HostedAiUseCase = "ai_column" | "copilot" | "agent";
|
|
25
26
|
/**
|
|
26
|
-
* The Copilot's own level set.
|
|
27
|
-
*
|
|
28
|
-
* created before that release carry them in `copilot_sessions.reasoning_level`
|
|
29
|
-
* and must keep resolving to the model they were priced against.
|
|
27
|
+
* The Copilot's own level set. Balanced is the default; the composer also
|
|
28
|
+
* offers explicit Auto/Fast/Max choices (Philipp, 2026-10-10).
|
|
30
29
|
*
|
|
31
30
|
* Deliberately NOT folded into `HostedAiLevel`: that type is shared with
|
|
32
31
|
* `AiReasoningLevel` and with the ai_column/agent registries, neither of which
|
|
@@ -100,19 +99,14 @@ export declare const MANAGED_AI_TIER_LABELS: Record<HostedAiLevel, string>;
|
|
|
100
99
|
*
|
|
101
100
|
* Separate from MANAGED_AI_TIER_LABELS rather than folded into it because the
|
|
102
101
|
* Copilot's level set is not the AI column's: only the Copilot has `auto`, and
|
|
103
|
-
*
|
|
104
|
-
* sell Fast/Balanced/Max and still mean it.
|
|
105
|
-
*
|
|
106
|
-
* Every current session reads "Oxygen Auto". The other three exist so a session
|
|
107
|
-
* created before 2026-09-21 still renders as the thing its owner picked, rather
|
|
108
|
-
* than being relabelled under them.
|
|
102
|
+
* the Copilot offers Auto alongside Fast/Balanced/Max.
|
|
109
103
|
*/
|
|
110
104
|
export declare const COPILOT_TIER_LABELS: Record<CopilotAiLevel, string>;
|
|
111
105
|
/**
|
|
112
|
-
* The tier
|
|
113
|
-
* Auto" is the whole customer-facing choice (Philipp, 2026-09-21).
|
|
106
|
+
* The tier a new Copilot session gets unless its owner chooses another.
|
|
114
107
|
*
|
|
115
|
-
*
|
|
108
|
+
* Balanced is the founder-selected default (2026-10-10), replacing Auto.
|
|
109
|
+
* Historically it was `high`, which is why 83% of production sessions ran the most
|
|
116
110
|
* expensive tier -- 110 of 132 sessions across 69 of 81 tenants -- while the
|
|
117
111
|
* composer's own dropdown told the user that medium was "The default for most
|
|
118
112
|
* GTM work". Nobody chose that; we defaulted them into it.
|
|
@@ -161,6 +155,21 @@ export declare const AGENT_DEFAULT_LEVEL: HostedAiLevel;
|
|
|
161
155
|
* credit reservation for the wrong model and breaks the spend ceiling.
|
|
162
156
|
*/
|
|
163
157
|
export declare function findHostedAiModelSpec(model: string, preferredUseCase?: HostedAiUseCase): HostedAiModelSpec | null;
|
|
158
|
+
/**
|
|
159
|
+
* The models the registry runs (a tier's model or fallback, or a retained
|
|
160
|
+
* Copilot spec) that accept image input: OpenRouter lists "image" in their
|
|
161
|
+
* `architecture.input_modalities` (read 2026-10-10).
|
|
162
|
+
*
|
|
163
|
+
* Whether a model can look at a rendered page belongs to the model, so it is
|
|
164
|
+
* kept here beside it. It used to be only the deploy allowlist
|
|
165
|
+
* OXYGEN_VISUAL_VISION_MODELS, which named the Kimi models alone while the
|
|
166
|
+
* Copilot moved to Sol and then to Haiku on 2026-10-10. The Copilot then stopped
|
|
167
|
+
* seeing its own renders without an error anywhere, and production never set
|
|
168
|
+
* the allowlist at all (Tim, 2026-10-10: the model catalog decides). The
|
|
169
|
+
* allowlist still adds exact model ids.
|
|
170
|
+
*/
|
|
171
|
+
export declare const IMAGE_INPUT_MODELS: ReadonlySet<string>;
|
|
172
|
+
export declare function modelAcceptsImages(model: string): boolean;
|
|
164
173
|
export declare function resolveHostedAiModel(input: {
|
|
165
174
|
useCase: HostedAiUseCase;
|
|
166
175
|
level: CopilotAiLevel;
|
|
@@ -222,9 +231,10 @@ export declare function redactManagedModelIds<T>(value: T, options?: {
|
|
|
222
231
|
*/
|
|
223
232
|
export declare function hostedAiDefaultModels(useCase: HostedAiUseCase): Record<HostedAiLevel, string>;
|
|
224
233
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
234
|
+
* Whether this deployment runs the Copilot for anyone. Who may use it is
|
|
235
|
+
* `copilotAudience` / `isCopilotAvailableTo` in `./copilot-access.ts`; only the
|
|
236
|
+
* self-hosted dev and production deployments read the flag, and NODE_ENV or a
|
|
237
|
+
* request host can never open it.
|
|
228
238
|
*/
|
|
229
239
|
export declare function isCopilotEnabled(env?: Record<string, string | undefined>): boolean;
|
|
230
240
|
/** Historical cost reconciliation may settle receipts without running Copilot. */
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
* overrides (`OXYGEN_COPILOT_MODEL_*` / `OXYGEN_COPILOT_FALLBACK_*`) applied
|
|
18
18
|
* here by `resolveHostedAiModel`.
|
|
19
19
|
*
|
|
20
|
-
* Kept as a leaf module (mirrors ./pricing-sheet.ts):
|
|
21
|
-
*
|
|
20
|
+
* Kept as a leaf module (mirrors ./pricing-sheet.ts): it imports only the
|
|
21
|
+
* dependency-free Copilot access leaf, never the shared barrel, so any package
|
|
22
|
+
* can re-source the catalog without dragging the barrel in.
|
|
22
23
|
*/
|
|
24
|
+
import { copilotAudience, isCopilotFlagOn } from "./copilot-access.js";
|
|
23
25
|
export const HOSTED_AI_MODEL_REGISTRY = {
|
|
24
26
|
/**
|
|
25
27
|
* Historical calibration of the September AI-column tiers, re-picked
|
|
@@ -112,8 +114,8 @@ export const HOSTED_AI_MODEL_REGISTRY = {
|
|
|
112
114
|
},
|
|
113
115
|
copilot: {
|
|
114
116
|
/**
|
|
115
|
-
* "Oxygen Auto" -- the
|
|
116
|
-
* conversation thread
|
|
117
|
+
* "Oxygen Auto" -- the earlier default, retained as an explicit choice.
|
|
118
|
+
* Its conversation thread stays fixed for its whole life. It is NOT a router: the
|
|
117
119
|
* model never changes mid-session, because an OpenRouter prompt cache is
|
|
118
120
|
* model-scoped and a mid-turn switch would turn a ~66% cache HIT rate into a
|
|
119
121
|
* cache WRITE on a fresh model, ten times a turn. Models only change below
|
|
@@ -138,12 +140,16 @@ export const HOSTED_AI_MODEL_REGISTRY = {
|
|
|
138
140
|
model: "openai/gpt-6.1-sol",
|
|
139
141
|
displayName: "Oxygen Auto",
|
|
140
142
|
// Approved 2026-10-10 for NEW sessions. Existing sessions retain their
|
|
141
|
-
// persisted resolved_model; Kimi's priced spec survives
|
|
143
|
+
// persisted resolved_model; Kimi's priced spec survives in the legacy map.
|
|
142
144
|
fallbackModels: ["anthropic/claude-sonnet-5.5"],
|
|
143
145
|
reasoningEffort: "medium",
|
|
144
146
|
estPromptUsdPerM: 2,
|
|
145
147
|
estCompletionUsdPerM: 10,
|
|
146
|
-
|
|
148
|
+
// Reasoning tokens count against this cap, so 8,192 left a long tool call
|
|
149
|
+
// (a full sequence draft, a wide table write) to be cut off mid-argument,
|
|
150
|
+
// which fails the turn on the second time. Credit holds are sized from the
|
|
151
|
+
// expected output (billing.ts), so this moves only the conservative clamp.
|
|
152
|
+
maxOutputTokens: 16_384,
|
|
147
153
|
},
|
|
148
154
|
low: {
|
|
149
155
|
// The errand model: sub-agents, compaction summaries and auto-titles. A
|
|
@@ -177,29 +183,24 @@ export const HOSTED_AI_MODEL_REGISTRY = {
|
|
|
177
183
|
maxOutputTokens: 8192,
|
|
178
184
|
},
|
|
179
185
|
medium: {
|
|
180
|
-
model: "
|
|
186
|
+
model: "anthropic/claude-haiku-5.5",
|
|
181
187
|
displayName: "Oxygen Balanced",
|
|
182
|
-
fallbackModels: [],
|
|
183
|
-
reasoningEffort: "
|
|
184
|
-
estPromptUsdPerM: 0.
|
|
185
|
-
estCompletionUsdPerM: 0.
|
|
186
|
-
|
|
188
|
+
fallbackModels: ["deepseek/deepseek-v4.1-flash"],
|
|
189
|
+
reasoningEffort: "medium",
|
|
190
|
+
estPromptUsdPerM: 0.1,
|
|
191
|
+
estCompletionUsdPerM: 0.5,
|
|
192
|
+
// A conversation tier (the default since #5225), so Auto's cap (see Auto).
|
|
193
|
+
maxOutputTokens: 16_384,
|
|
187
194
|
},
|
|
188
|
-
/**
|
|
189
|
-
* Retained only for sessions created before "Oxygen Auto" shipped, whose
|
|
190
|
-
* `reasoning_level` is already 'high' on the row, and for Auto sessions
|
|
191
|
-
* pinned to Kimi before the 2026-10-10 refresh. Keep its pricing, effort
|
|
192
|
-
* and GLM fallback so changing the new-session default never switches an
|
|
193
|
-
* existing conversation's model or loses its prompt cache.
|
|
194
|
-
*/
|
|
195
195
|
high: {
|
|
196
|
-
model: "
|
|
196
|
+
model: "openai/gpt-6.1-sol",
|
|
197
197
|
displayName: "Oxygen Max",
|
|
198
|
-
fallbackModels: ["
|
|
198
|
+
fallbackModels: ["anthropic/claude-sonnet-5.5"],
|
|
199
199
|
reasoningEffort: "medium",
|
|
200
|
-
estPromptUsdPerM:
|
|
201
|
-
estCompletionUsdPerM:
|
|
202
|
-
|
|
200
|
+
estPromptUsdPerM: 2,
|
|
201
|
+
estCompletionUsdPerM: 10,
|
|
202
|
+
// A conversation tier, so Auto's cap (see Auto).
|
|
203
|
+
maxOutputTokens: 16_384,
|
|
203
204
|
},
|
|
204
205
|
},
|
|
205
206
|
agent: {
|
|
@@ -231,6 +232,27 @@ export const HOSTED_AI_MODEL_REGISTRY = {
|
|
|
231
232
|
},
|
|
232
233
|
},
|
|
233
234
|
};
|
|
235
|
+
/** Persisted conversations keep their original pricing, effort and fallbacks. */
|
|
236
|
+
const LEGACY_COPILOT_MODEL_SPECS = [
|
|
237
|
+
{
|
|
238
|
+
model: "deepseek/deepseek-v4-pro",
|
|
239
|
+
displayName: "Oxygen Balanced",
|
|
240
|
+
fallbackModels: [],
|
|
241
|
+
reasoningEffort: "high",
|
|
242
|
+
estPromptUsdPerM: 0.435,
|
|
243
|
+
estCompletionUsdPerM: 0.87,
|
|
244
|
+
maxOutputTokens: 8192,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
model: "moonshotai/kimi-k3",
|
|
248
|
+
displayName: "Oxygen Max",
|
|
249
|
+
fallbackModels: ["z-ai/glm-5.3"],
|
|
250
|
+
reasoningEffort: "medium",
|
|
251
|
+
estPromptUsdPerM: 1.7,
|
|
252
|
+
estCompletionUsdPerM: 8.5,
|
|
253
|
+
maxOutputTokens: 8192,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
234
256
|
/**
|
|
235
257
|
* Managed AI-column model ids that USED to back a tier, mapped to the tier they
|
|
236
258
|
* billed at. A managed column saved with one of these pinned keeps validating
|
|
@@ -293,27 +315,22 @@ export const MANAGED_AI_TIER_LABELS = {
|
|
|
293
315
|
*
|
|
294
316
|
* Separate from MANAGED_AI_TIER_LABELS rather than folded into it because the
|
|
295
317
|
* Copilot's level set is not the AI column's: only the Copilot has `auto`, and
|
|
296
|
-
*
|
|
297
|
-
* sell Fast/Balanced/Max and still mean it.
|
|
298
|
-
*
|
|
299
|
-
* Every current session reads "Oxygen Auto". The other three exist so a session
|
|
300
|
-
* created before 2026-09-21 still renders as the thing its owner picked, rather
|
|
301
|
-
* than being relabelled under them.
|
|
318
|
+
* the Copilot offers Auto alongside Fast/Balanced/Max.
|
|
302
319
|
*/
|
|
303
320
|
export const COPILOT_TIER_LABELS = {
|
|
304
321
|
...MANAGED_AI_TIER_LABELS,
|
|
305
322
|
auto: "Oxygen Auto",
|
|
306
323
|
};
|
|
307
324
|
/**
|
|
308
|
-
* The tier
|
|
309
|
-
* Auto" is the whole customer-facing choice (Philipp, 2026-09-21).
|
|
325
|
+
* The tier a new Copilot session gets unless its owner chooses another.
|
|
310
326
|
*
|
|
311
|
-
*
|
|
327
|
+
* Balanced is the founder-selected default (2026-10-10), replacing Auto.
|
|
328
|
+
* Historically it was `high`, which is why 83% of production sessions ran the most
|
|
312
329
|
* expensive tier -- 110 of 132 sessions across 69 of 81 tenants -- while the
|
|
313
330
|
* composer's own dropdown told the user that medium was "The default for most
|
|
314
331
|
* GTM work". Nobody chose that; we defaulted them into it.
|
|
315
332
|
*/
|
|
316
|
-
export const COPILOT_DEFAULT_LEVEL = "
|
|
333
|
+
export const COPILOT_DEFAULT_LEVEL = "medium";
|
|
317
334
|
/**
|
|
318
335
|
* The tier the Copilot's ERRANDS run at: sub-agent children, compaction
|
|
319
336
|
* summaries, and auto-titles.
|
|
@@ -385,9 +402,40 @@ export function findHostedAiModelSpec(model, preferredUseCase) {
|
|
|
385
402
|
if (spec.model === model)
|
|
386
403
|
return spec;
|
|
387
404
|
}
|
|
405
|
+
if (useCase === "copilot") {
|
|
406
|
+
const legacy = LEGACY_COPILOT_MODEL_SPECS.find((spec) => spec.model === model);
|
|
407
|
+
if (legacy)
|
|
408
|
+
return legacy;
|
|
409
|
+
}
|
|
388
410
|
}
|
|
389
411
|
return null;
|
|
390
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* The models the registry runs (a tier's model or fallback, or a retained
|
|
415
|
+
* Copilot spec) that accept image input: OpenRouter lists "image" in their
|
|
416
|
+
* `architecture.input_modalities` (read 2026-10-10).
|
|
417
|
+
*
|
|
418
|
+
* Whether a model can look at a rendered page belongs to the model, so it is
|
|
419
|
+
* kept here beside it. It used to be only the deploy allowlist
|
|
420
|
+
* OXYGEN_VISUAL_VISION_MODELS, which named the Kimi models alone while the
|
|
421
|
+
* Copilot moved to Sol and then to Haiku on 2026-10-10. The Copilot then stopped
|
|
422
|
+
* seeing its own renders without an error anywhere, and production never set
|
|
423
|
+
* the allowlist at all (Tim, 2026-10-10: the model catalog decides). The
|
|
424
|
+
* allowlist still adds exact model ids.
|
|
425
|
+
*/
|
|
426
|
+
export const IMAGE_INPUT_MODELS = new Set([
|
|
427
|
+
"openai/gpt-6.1-sol",
|
|
428
|
+
"openai/gpt-6-luna",
|
|
429
|
+
"anthropic/claude-sonnet-5.5",
|
|
430
|
+
"anthropic/claude-haiku-5.5",
|
|
431
|
+
"moonshotai/kimi-k3",
|
|
432
|
+
"moonshotai/kimi-k2.6",
|
|
433
|
+
"deepseek/deepseek-v4.1-flash",
|
|
434
|
+
"google/gemini-3.8-flash",
|
|
435
|
+
]);
|
|
436
|
+
export function modelAcceptsImages(model) {
|
|
437
|
+
return IMAGE_INPUT_MODELS.has(model);
|
|
438
|
+
}
|
|
391
439
|
export function resolveHostedAiModel(input) {
|
|
392
440
|
// `auto` exists only on the copilot registry. Asking ai_column or agent for it
|
|
393
441
|
// is a caller bug, and resolving `undefined` here would hand the request
|
|
@@ -485,6 +533,14 @@ function managedModelLabels(preferredUseCase, env = typeof process === "undefine
|
|
|
485
533
|
}
|
|
486
534
|
}
|
|
487
535
|
}
|
|
536
|
+
for (const spec of LEGACY_COPILOT_MODEL_SPECS) {
|
|
537
|
+
if (!labels.has(spec.model))
|
|
538
|
+
labels.set(spec.model, spec.displayName);
|
|
539
|
+
for (const fallback of spec.fallbackModels) {
|
|
540
|
+
if (!labels.has(fallback))
|
|
541
|
+
labels.set(fallback, BACKUP_MODEL_LABEL);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
488
544
|
return labels;
|
|
489
545
|
}
|
|
490
546
|
/**
|
|
@@ -590,20 +646,15 @@ export function hostedAiDefaultModels(useCase) {
|
|
|
590
646
|
};
|
|
591
647
|
}
|
|
592
648
|
/**
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
649
|
+
* Whether this deployment runs the Copilot for anyone. Who may use it is
|
|
650
|
+
* `copilotAudience` / `isCopilotAvailableTo` in `./copilot-access.ts`; only the
|
|
651
|
+
* self-hosted dev and production deployments read the flag, and NODE_ENV or a
|
|
652
|
+
* request host can never open it.
|
|
596
653
|
*/
|
|
597
654
|
export function isCopilotEnabled(env = process.env) {
|
|
598
|
-
|
|
599
|
-
return false;
|
|
600
|
-
return isCopilotAccountingEnabled(env);
|
|
655
|
+
return copilotAudience(env) !== "off";
|
|
601
656
|
}
|
|
602
657
|
/** Historical cost reconciliation may settle receipts without running Copilot. */
|
|
603
658
|
export function isCopilotAccountingEnabled(env = process.env) {
|
|
604
|
-
|
|
605
|
-
if (typeof raw !== "string")
|
|
606
|
-
return false;
|
|
607
|
-
const normalized = raw.trim().toLowerCase();
|
|
608
|
-
return normalized === "1" || normalized === "true";
|
|
659
|
+
return isCopilotFlagOn(env);
|
|
609
660
|
}
|
|
@@ -110,7 +110,7 @@ export type LlmTracingClient = {
|
|
|
110
110
|
};
|
|
111
111
|
/**
|
|
112
112
|
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
113
|
-
* "1"/"true" (trimmed, case-insensitive)
|
|
113
|
+
* "1"/"true" (trimmed, case-insensitive), both keys and an explicit self-hosted URL.
|
|
114
114
|
*/
|
|
115
115
|
export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
|
|
116
116
|
export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
|