mixdog 0.9.69 → 0.9.70
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/package.json +3 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +8 -1
- package/src/runtime/media/adapters/codex-image.mjs +109 -0
- package/src/runtime/media/adapters/gemini-image.mjs +68 -0
- package/src/runtime/media/adapters/gemini-video.mjs +119 -0
- package/src/runtime/media/adapters/xai-media.mjs +116 -0
- package/src/runtime/media/auth.mjs +51 -0
- package/src/runtime/media/index.mjs +17 -0
- package/src/runtime/media/jobs.mjs +174 -0
- package/src/runtime/media/lanes.mjs +230 -0
- package/src/runtime/media/store.mjs +164 -0
- package/src/runtime/media/upstream-error.mjs +39 -0
- package/src/session-runtime/channel-config-api.mjs +12 -1
- package/src/session-runtime/media-api.mjs +47 -0
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/runtime-core.mjs +17 -1
- package/src/session-runtime/warmup-schedulers.mjs +14 -1
- package/src/session-runtime/workflow-agents-api.mjs +29 -4
- package/src/tui/dist/index.mjs +20 -1
- package/src/tui/engine/session-api-ext.mjs +14 -0
- package/src/tui/engine.mjs +39 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.70",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -85,6 +85,8 @@
|
|
|
85
85
|
"test:session": "node --test scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs scripts/session-heartbeat-lifecycle-test.mjs scripts/remote-transition-order-test.mjs",
|
|
86
86
|
"test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
|
|
87
87
|
"test:workflow-editor": "node --test scripts/workflow-id-test.mjs scripts/workflow-pack-editor-test.mjs",
|
|
88
|
+
"test:route-scope": "node --test scripts/route-scope-isolation-test.mjs",
|
|
89
|
+
"test:schedule-reload": "node --test scripts/schedule-reload-arm-test.mjs",
|
|
88
90
|
"failures": "node scripts/tool-failures.mjs",
|
|
89
91
|
"trace:llm": "node scripts/llm-trace-summary.mjs",
|
|
90
92
|
"diag:sessions": "node scripts/session-diag.mjs",
|
|
@@ -477,7 +477,14 @@ async function reloadRuntimeConfig() {
|
|
|
477
477
|
getConfig().interactive ?? [],
|
|
478
478
|
// Single resolved main-channel id used for the schedule `channel` flag.
|
|
479
479
|
getConfig().channelId,
|
|
480
|
-
|
|
480
|
+
// The scheduler must be RE-ARMED by the reload whenever it is supposed to
|
|
481
|
+
// be running. `restart: false` only destroys the cron/one-shot bindings and
|
|
482
|
+
// hands lifecycle back to the caller — but on an automation-only install
|
|
483
|
+
// (no messaging backend) startAutomationRuntime() is already past its
|
|
484
|
+
// `automationRunning` guard, so nobody would ever call scheduler.start()
|
|
485
|
+
// again and every saved/edited schedule stayed silently disarmed until the
|
|
486
|
+
// daemon restarted.
|
|
487
|
+
{ restart: automationRunning || getBridgeRuntimeConnected() }
|
|
481
488
|
);
|
|
482
489
|
const nextBackend = createBackend(getConfig());
|
|
483
490
|
const backendTypeChanged = (nextBackend?.name || "") !== previousBackendName;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT OAuth image adapter (`openai-oauth` lane).
|
|
3
|
+
*
|
|
4
|
+
* Runs the hosted `image_generation` tool on the Codex responses backend — the
|
|
5
|
+
* same endpoint/headers the chat provider uses, so the subscription session is
|
|
6
|
+
* the only credential involved. The final image arrives as base64 on the
|
|
7
|
+
* image_generation_call item; partial frames are kept as a fallback.
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
import { resolveCodexAuth } from '../auth.mjs';
|
|
11
|
+
import { mediaError } from '../lanes.mjs';
|
|
12
|
+
import { upstreamError } from '../upstream-error.mjs';
|
|
13
|
+
import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL } from '../../agent/orchestrator/providers/openai-oauth.mjs';
|
|
14
|
+
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 400_000;
|
|
16
|
+
|
|
17
|
+
function imageTool(options = {}) {
|
|
18
|
+
const tool = { type: 'image_generation' };
|
|
19
|
+
const size = String(options.size || 'auto');
|
|
20
|
+
if (size && size !== 'auto') tool.size = size;
|
|
21
|
+
const quality = String(options.quality || 'auto');
|
|
22
|
+
if (quality && quality !== 'auto') tool.quality = quality;
|
|
23
|
+
return tool;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectImage(event, state) {
|
|
27
|
+
const item = event?.item;
|
|
28
|
+
if (event?.type === 'response.output_item.done' && item?.type === 'image_generation_call') {
|
|
29
|
+
if (typeof item.result === 'string' && item.result.length > 0) state.final = item.result;
|
|
30
|
+
if (typeof item.revised_prompt === 'string') state.revisedPrompt = item.revised_prompt;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (event?.type === 'response.image_generation_call.partial_image') {
|
|
34
|
+
const partial = event?.partial_image_b64;
|
|
35
|
+
if (typeof partial === 'string' && partial.length > (state.partial?.length || 0)) state.partial = partial;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function generateImage({ model, prompt, options = {}, references = [], signal }) {
|
|
40
|
+
const auth = await resolveCodexAuth();
|
|
41
|
+
// Reference images ride as input_image parts on the user turn — the same
|
|
42
|
+
// shape the chat path uses for pasted images.
|
|
43
|
+
const content = [
|
|
44
|
+
...references.map((ref) => ({
|
|
45
|
+
type: 'input_image',
|
|
46
|
+
image_url: `data:${ref.mime || 'image/png'};base64,${ref.base64}`,
|
|
47
|
+
})),
|
|
48
|
+
{ type: 'input_text', text: prompt },
|
|
49
|
+
];
|
|
50
|
+
const body = {
|
|
51
|
+
model,
|
|
52
|
+
stream: true,
|
|
53
|
+
store: false,
|
|
54
|
+
instructions: 'You generate images with the image_generation tool. Call the tool once for the user request; do not ask follow-up questions.',
|
|
55
|
+
input: [{ type: 'message', role: 'user', content }],
|
|
56
|
+
tools: [imageTool(options)],
|
|
57
|
+
tool_choice: 'auto',
|
|
58
|
+
};
|
|
59
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
60
|
+
const res = await fetch(CODEX_RESPONSES_URL, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${auth.access_token}`,
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
Accept: 'text/event-stream',
|
|
66
|
+
'OpenAI-Beta': 'responses=experimental',
|
|
67
|
+
originator: CODEX_OAUTH_ORIGINATOR,
|
|
68
|
+
'chatgpt-account-id': auth.account_id || '',
|
|
69
|
+
'x-client-request-id': randomBytes(16).toString('hex'),
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok || !res.body) throw upstreamError('ChatGPT image', res.status, await res.text().catch(() => ''));
|
|
75
|
+
|
|
76
|
+
const reader = res.body.getReader();
|
|
77
|
+
const decoder = new TextDecoder();
|
|
78
|
+
const state = { final: '', partial: '', revisedPrompt: null };
|
|
79
|
+
let buffer = '';
|
|
80
|
+
let failure = null;
|
|
81
|
+
try {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
buffer += decoder.decode(value, { stream: true });
|
|
86
|
+
const lines = buffer.split('\n');
|
|
87
|
+
buffer = lines.pop() || '';
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (!line.startsWith('data:')) continue;
|
|
90
|
+
const raw = line.slice(5).trim();
|
|
91
|
+
if (!raw || raw === '[DONE]') continue;
|
|
92
|
+
let event;
|
|
93
|
+
try { event = JSON.parse(raw); } catch { continue; }
|
|
94
|
+
if (event?.type === 'response.failed' || event?.type === 'error') {
|
|
95
|
+
failure = event?.response?.error?.message || event?.message || 'stream failed';
|
|
96
|
+
}
|
|
97
|
+
collectImage(event, state);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
try { reader.releaseLock(); } catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const b64 = state.final || state.partial;
|
|
105
|
+
if (!b64) {
|
|
106
|
+
throw mediaError(`ChatGPT returned no image data${failure ? `: ${failure}` : ''}`, 'MEDIA_EMPTY_RESULT', 502);
|
|
107
|
+
}
|
|
108
|
+
return { bytes: Buffer.from(b64, 'base64'), mime: 'image/png', revisedPrompt: state.revisedPrompt };
|
|
109
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini image adapter (API key lane).
|
|
3
|
+
*
|
|
4
|
+
* Calls the Generative Language `generateContent` route and pulls the first
|
|
5
|
+
* inline image part out of the candidate. Aspect ratio rides in `imageConfig`,
|
|
6
|
+
* which older image models reject — a 400 there retries once without it so a
|
|
7
|
+
* control the model does not know never fails the whole generation.
|
|
8
|
+
*/
|
|
9
|
+
import { resolveGeminiKey } from '../auth.mjs';
|
|
10
|
+
import { mediaError } from '../lanes.mjs';
|
|
11
|
+
import { upstreamError } from '../upstream-error.mjs';
|
|
12
|
+
|
|
13
|
+
const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 180_000;
|
|
15
|
+
|
|
16
|
+
function requestBody(prompt, options, withImageConfig, references = []) {
|
|
17
|
+
const body = {
|
|
18
|
+
contents: [{
|
|
19
|
+
role: 'user',
|
|
20
|
+
parts: [
|
|
21
|
+
...references.map((ref) => ({
|
|
22
|
+
inlineData: { mimeType: ref.mime || 'image/png', data: ref.base64 },
|
|
23
|
+
})),
|
|
24
|
+
{ text: prompt },
|
|
25
|
+
],
|
|
26
|
+
}],
|
|
27
|
+
};
|
|
28
|
+
const aspect = String(options?.aspectRatio || 'auto');
|
|
29
|
+
if (withImageConfig && aspect !== 'auto') {
|
|
30
|
+
body.generationConfig = { imageConfig: { aspectRatio: aspect } };
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function post(model, key, body, signal) {
|
|
36
|
+
return await fetch(`${BASE_URL}/${encodeURIComponent(model)}:generateContent`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(Boolean)),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function generateImage({ model, prompt, options = {}, references = [], signal }) {
|
|
45
|
+
const key = resolveGeminiKey();
|
|
46
|
+
const wantsImageConfig = String(options.aspectRatio || 'auto') !== 'auto';
|
|
47
|
+
let res = await post(model, key, requestBody(prompt, options, wantsImageConfig, references), signal);
|
|
48
|
+
if (!res.ok && res.status === 400 && wantsImageConfig) {
|
|
49
|
+
res = await post(model, key, requestBody(prompt, options, false, references), signal);
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) throw upstreamError('Gemini image', res.status, await res.text().catch(() => ''));
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
const parts = data?.candidates?.[0]?.content?.parts || [];
|
|
54
|
+
const image = parts.find((part) => part?.inlineData?.data);
|
|
55
|
+
if (!image) {
|
|
56
|
+
const refusal = parts.find((part) => typeof part?.text === 'string')?.text || '';
|
|
57
|
+
throw mediaError(
|
|
58
|
+
`Gemini returned no image data${refusal ? `: ${refusal.slice(0, 200)}` : ''}`,
|
|
59
|
+
'MEDIA_EMPTY_RESULT',
|
|
60
|
+
502,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
bytes: Buffer.from(image.inlineData.data, 'base64'),
|
|
65
|
+
mime: image.inlineData.mimeType || 'image/png',
|
|
66
|
+
revisedPrompt: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini video adapter (API key lane).
|
|
3
|
+
*
|
|
4
|
+
* Two different upstream shapes live behind one lane:
|
|
5
|
+
* - Omni Flash answers on the Interactions API in a single call, with the
|
|
6
|
+
* mp4 inline as base64 on a `model_output` step.
|
|
7
|
+
* - Veo 3.1 is a long-running predict: submit, poll the operation, then pull
|
|
8
|
+
* the sample URI with `alt=media`.
|
|
9
|
+
*/
|
|
10
|
+
import { resolveGeminiKey } from '../auth.mjs';
|
|
11
|
+
import { mediaError } from '../lanes.mjs';
|
|
12
|
+
import { upstreamError } from '../upstream-error.mjs';
|
|
13
|
+
|
|
14
|
+
const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
|
|
15
|
+
const OMNI_TIMEOUT_MS = 600_000;
|
|
16
|
+
const POLL_INTERVAL_MS = 8_000;
|
|
17
|
+
const TOTAL_TIMEOUT_MS = 900_000;
|
|
18
|
+
|
|
19
|
+
export function isOmniModel(model) {
|
|
20
|
+
return /omni/i.test(String(model || ''));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function aspectFor(options) {
|
|
24
|
+
const aspect = String(options?.aspectRatio || 'auto');
|
|
25
|
+
return aspect === 'auto' ? '16:9' : aspect;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function generateViaOmni({ model, prompt, options, references = [], signal, key }) {
|
|
29
|
+
// Omni takes a multimodal input array; a reference image switches the task
|
|
30
|
+
// from text-to-video to image-to-video.
|
|
31
|
+
const input = references.length
|
|
32
|
+
? [
|
|
33
|
+
...references.map((ref) => ({ type: 'image', data: ref.base64, mime_type: ref.mime || 'image/png' })),
|
|
34
|
+
{ type: 'text', text: prompt },
|
|
35
|
+
]
|
|
36
|
+
: prompt;
|
|
37
|
+
const res = await fetch(`${BASE_URL}/interactions`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
model,
|
|
42
|
+
input,
|
|
43
|
+
response_format: { type: 'video', aspect_ratio: aspectFor(options) },
|
|
44
|
+
generation_config: {
|
|
45
|
+
video_config: { task: references.length ? 'image_to_video' : 'text_to_video' },
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(OMNI_TIMEOUT_MS)].filter(Boolean)),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw upstreamError('Gemini Omni video', res.status, await res.text().catch(() => ''));
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
for (const step of data?.steps || []) {
|
|
53
|
+
const content = Array.isArray(step?.content) ? step.content : [step?.content].filter(Boolean);
|
|
54
|
+
const video = content.find((item) => item?.type === 'video' && typeof item?.data === 'string');
|
|
55
|
+
if (video) {
|
|
56
|
+
return { bytes: Buffer.from(video.data, 'base64'), mime: video.mime_type || 'video/mp4' };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw mediaError('Gemini Omni returned no video data', 'MEDIA_EMPTY_RESULT', 502);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function generateViaVeo({ model, prompt, options, references = [], signal, onProgress, key }) {
|
|
63
|
+
const headers = { 'Content-Type': 'application/json', 'x-goog-api-key': key };
|
|
64
|
+
const parameters = { aspectRatio: aspectFor(options) };
|
|
65
|
+
const instance = { prompt };
|
|
66
|
+
// Veo accepts a single seed image for image-to-video.
|
|
67
|
+
if (references[0]) {
|
|
68
|
+
instance.image = { bytesBase64Encoded: references[0].base64, mimeType: references[0].mime || 'image/png' };
|
|
69
|
+
}
|
|
70
|
+
const resolution = String(options?.resolution || '');
|
|
71
|
+
if (resolution === '720p' || resolution === '1080p') parameters.resolution = resolution;
|
|
72
|
+
// Veo takes discrete clip lengths; anything else is rejected upstream, so an
|
|
73
|
+
// out-of-contract value is dropped rather than forwarded.
|
|
74
|
+
const duration = Math.trunc(Number(options?.duration) || 0);
|
|
75
|
+
if ([4, 6, 8].includes(duration)) parameters.durationSeconds = duration;
|
|
76
|
+
|
|
77
|
+
const started = await fetch(`${BASE_URL}/models/${encodeURIComponent(model)}:predictLongRunning`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers,
|
|
80
|
+
body: JSON.stringify({ instances: [instance], parameters }),
|
|
81
|
+
signal,
|
|
82
|
+
});
|
|
83
|
+
if (!started.ok) throw upstreamError('Veo video', started.status, await started.text().catch(() => ''));
|
|
84
|
+
const operation = await started.json();
|
|
85
|
+
if (!operation?.name) throw mediaError('Veo returned no operation name', 'MEDIA_UPSTREAM_FAILED', 502);
|
|
86
|
+
|
|
87
|
+
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
|
|
88
|
+
for (;;) {
|
|
89
|
+
if (signal?.aborted) throw mediaError('canceled', 'MEDIA_CANCELED', 499);
|
|
90
|
+
if (Date.now() > deadline) throw mediaError('Veo poll budget exceeded', 'MEDIA_TIMEOUT', 504);
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
92
|
+
const poll = await fetch(`${BASE_URL}/${operation.name}`, { headers, signal });
|
|
93
|
+
if (!poll.ok) throw upstreamError('Veo poll', poll.status, await poll.text().catch(() => ''));
|
|
94
|
+
const data = await poll.json();
|
|
95
|
+
if (!data?.done) {
|
|
96
|
+
// The operation exposes no percentage; keep the UI moving with a coarse
|
|
97
|
+
// heartbeat instead of a fake number.
|
|
98
|
+
if (typeof onProgress === 'function') onProgress(50);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (data.error) {
|
|
102
|
+
throw mediaError(`Veo generation failed: ${data.error.message || 'unknown error'}`, 'MEDIA_UPSTREAM_FAILED', 502);
|
|
103
|
+
}
|
|
104
|
+
const sample = data?.response?.generateVideoResponse?.generatedSamples?.[0]
|
|
105
|
+
|| data?.response?.generatedVideos?.[0];
|
|
106
|
+
const uri = sample?.video?.uri || sample?.video?.fileUri;
|
|
107
|
+
if (!uri) throw mediaError('Veo finished without a video URI', 'MEDIA_EMPTY_RESULT', 502);
|
|
108
|
+
const file = await fetch(`${uri}${uri.includes('?') ? '&' : '?'}alt=media`, { headers, signal });
|
|
109
|
+
if (!file.ok) throw upstreamError('Veo download', file.status, await file.text().catch(() => ''));
|
|
110
|
+
return { bytes: Buffer.from(await file.arrayBuffer()), mime: 'video/mp4' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function generateVideo({ model, prompt, options = {}, references = [], signal, onProgress }) {
|
|
115
|
+
const key = resolveGeminiKey();
|
|
116
|
+
return isOmniModel(model)
|
|
117
|
+
? await generateViaOmni({ model, prompt, options, references, signal, key })
|
|
118
|
+
: await generateViaVeo({ model, prompt, options, references, signal, onProgress, key });
|
|
119
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xAI Imagine adapter (images + video) for the `grok-oauth` and `xai` lanes.
|
|
3
|
+
*
|
|
4
|
+
* Both lanes speak the same api.x.ai contract; only the bearer differs. Video
|
|
5
|
+
* is a submit + poll job: POST /videos/generations returns a request id and the
|
|
6
|
+
* poll route answers 202 while pending, 200 with a signed URL when done.
|
|
7
|
+
*/
|
|
8
|
+
import { resolveXaiAuth } from '../auth.mjs';
|
|
9
|
+
import { mediaError } from '../lanes.mjs';
|
|
10
|
+
import { upstreamError } from '../upstream-error.mjs';
|
|
11
|
+
|
|
12
|
+
const POLL_INTERVAL_MS = 4_000;
|
|
13
|
+
const START_TIMEOUT_MS = 60_000;
|
|
14
|
+
const TOTAL_TIMEOUT_MS = 900_000;
|
|
15
|
+
|
|
16
|
+
async function readError(res) {
|
|
17
|
+
const text = await res.text().catch(() => '');
|
|
18
|
+
return text.slice(0, 400);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sizeParams({ aspectRatio, resolution }) {
|
|
22
|
+
const params = {};
|
|
23
|
+
const aspect = String(aspectRatio || 'auto');
|
|
24
|
+
if (aspect && aspect !== 'auto') params.aspect_ratio = aspect;
|
|
25
|
+
const res = String(resolution || '').toLowerCase();
|
|
26
|
+
if (res === '1k' || res === '2k') params.resolution = res;
|
|
27
|
+
return params;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reference images ride as data URLs — xAI accepts them in place of a host. */
|
|
31
|
+
function referenceUrl(reference) {
|
|
32
|
+
const data = String(reference?.base64 || '');
|
|
33
|
+
if (data.startsWith('data:')) return data;
|
|
34
|
+
return `data:${reference?.mime || 'image/png'};base64,${data}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function generateImage({ lane, model, prompt, options = {}, references = [], signal }) {
|
|
38
|
+
const { baseURL, token } = await resolveXaiAuth(lane);
|
|
39
|
+
const refs = references.map(referenceUrl).map((url) => ({ type: 'image_url', url }));
|
|
40
|
+
const body = {
|
|
41
|
+
model,
|
|
42
|
+
prompt,
|
|
43
|
+
n: 1,
|
|
44
|
+
response_format: 'b64_json',
|
|
45
|
+
// One reference edits that image; several composite into a new one.
|
|
46
|
+
...(refs.length === 1 ? { image: refs[0] } : refs.length > 1 ? { images: refs } : {}),
|
|
47
|
+
...sizeParams(options),
|
|
48
|
+
};
|
|
49
|
+
const route = refs.length ? 'images/edits' : 'images/generations';
|
|
50
|
+
const res = await fetch(`${baseURL}/${route}`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
signal,
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) throw upstreamError('xAI image', res.status, await readError(res));
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
const entry = data?.data?.[0];
|
|
59
|
+
if (!entry?.b64_json) throw mediaError('xAI returned no image data', 'MEDIA_EMPTY_RESULT', 502);
|
|
60
|
+
return {
|
|
61
|
+
bytes: Buffer.from(entry.b64_json, 'base64'),
|
|
62
|
+
mime: entry.mime_type || 'image/png',
|
|
63
|
+
revisedPrompt: entry.revised_prompt || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function generateVideo({ lane, model, prompt, options = {}, references = [], signal, onProgress }) {
|
|
68
|
+
const { baseURL, token } = await resolveXaiAuth(lane);
|
|
69
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
|
|
70
|
+
const duration = Math.min(15, Math.max(1, Math.trunc(Number(options.duration) || 5)));
|
|
71
|
+
const resolution = ['480p', '720p', '1080p'].includes(options.resolution) ? options.resolution : '480p';
|
|
72
|
+
const body = { model, prompt, duration, resolution };
|
|
73
|
+
const aspect = String(options.aspectRatio || 'auto');
|
|
74
|
+
if (aspect && aspect !== 'auto') body.aspect_ratio = aspect;
|
|
75
|
+
// Mode is inferred from the reference count: 1 = image-to-video,
|
|
76
|
+
// 2+ = reference-to-video (capped at the documented 7).
|
|
77
|
+
const refs = references.slice(0, 7).map(referenceUrl);
|
|
78
|
+
if (refs.length === 1) body.image = { url: refs[0] };
|
|
79
|
+
else if (refs.length > 1) body.reference_images = refs.map((url) => ({ url }));
|
|
80
|
+
|
|
81
|
+
const started = await fetch(`${baseURL}/videos/generations`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers,
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(START_TIMEOUT_MS)].filter(Boolean)),
|
|
86
|
+
});
|
|
87
|
+
if (!started.ok) throw upstreamError('xAI video', started.status, await readError(started));
|
|
88
|
+
const startData = await started.json();
|
|
89
|
+
const requestId = startData?.request_id || startData?.id;
|
|
90
|
+
if (!requestId) throw mediaError('xAI video start returned no request id', 'MEDIA_UPSTREAM_FAILED', 502);
|
|
91
|
+
|
|
92
|
+
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
|
|
93
|
+
for (;;) {
|
|
94
|
+
if (signal?.aborted) throw mediaError('canceled', 'MEDIA_CANCELED', 499);
|
|
95
|
+
if (Date.now() > deadline) throw mediaError('xAI video poll budget exceeded', 'MEDIA_TIMEOUT', 504);
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
97
|
+
const poll = await fetch(`${baseURL}/videos/${requestId}`, { headers, signal });
|
|
98
|
+
if (!poll.ok && poll.status !== 202) throw upstreamError('xAI video poll', poll.status, await readError(poll));
|
|
99
|
+
const data = await poll.json().catch(() => ({}));
|
|
100
|
+
if (typeof data?.progress === 'number' && typeof onProgress === 'function') onProgress(data.progress);
|
|
101
|
+
if (data?.status === 'done') {
|
|
102
|
+
const url = data?.video?.url;
|
|
103
|
+
if (!url) throw mediaError('xAI video finished without a URL', 'MEDIA_EMPTY_RESULT', 502);
|
|
104
|
+
const file = await fetch(url, { signal });
|
|
105
|
+
if (!file.ok) throw mediaError(`xAI video download failed (${file.status})`, 'MEDIA_UPSTREAM_FAILED', file.status);
|
|
106
|
+
return {
|
|
107
|
+
bytes: Buffer.from(await file.arrayBuffer()),
|
|
108
|
+
mime: 'video/mp4',
|
|
109
|
+
durationSeconds: Number(data?.video?.duration) || duration,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (data?.status === 'failed' || data?.status === 'expired') {
|
|
113
|
+
throw mediaError(`xAI video ${data.status}${data?.error?.code ? `: ${data.error.code}` : ''}`, 'MEDIA_UPSTREAM_FAILED', 502);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolution for media lanes.
|
|
3
|
+
*
|
|
4
|
+
* OAuth lanes reuse the chat providers' token stores (including their refresh
|
|
5
|
+
* paths) so the Studio never holds a second copy of a session. API-key lanes
|
|
6
|
+
* read the same keychain-backed store the agent providers use.
|
|
7
|
+
*/
|
|
8
|
+
import { getAgentApiKey } from '../shared/provider-api-key.mjs';
|
|
9
|
+
|
|
10
|
+
export { hasGrokOAuthCredentials } from '../agent/orchestrator/providers/oauth-credential-probes.mjs';
|
|
11
|
+
|
|
12
|
+
const XAI_BASE_URL = 'https://api.x.ai/v1';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bearer for the xAI media endpoints. `grok-oauth` refreshes through the chat
|
|
16
|
+
* provider; `xai` uses the stored API key. Both hit the same api.x.ai routes.
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveXaiAuth(laneId) {
|
|
19
|
+
if (laneId === 'xai') {
|
|
20
|
+
const key = getAgentApiKey('xai');
|
|
21
|
+
if (!key) {
|
|
22
|
+
const err = new Error('xAI API key is not configured');
|
|
23
|
+
err.code = 'MEDIA_LANE_UNAUTHENTICATED';
|
|
24
|
+
err.status = 401;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
return { baseURL: XAI_BASE_URL, token: key };
|
|
28
|
+
}
|
|
29
|
+
const { GrokOAuthProvider } = await import('../agent/orchestrator/providers/grok-oauth.mjs');
|
|
30
|
+
const provider = new GrokOAuthProvider({});
|
|
31
|
+
const tokens = await provider.ensureAuth();
|
|
32
|
+
return { baseURL: XAI_BASE_URL, token: tokens.access_token };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Codex (ChatGPT OAuth) auth record: access token + account id for headers. */
|
|
36
|
+
export async function resolveCodexAuth() {
|
|
37
|
+
const { OpenAIOAuthProvider } = await import('../agent/orchestrator/providers/openai-oauth.mjs');
|
|
38
|
+
const provider = new OpenAIOAuthProvider({});
|
|
39
|
+
return await provider.ensureAuth();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveGeminiKey() {
|
|
43
|
+
const key = getAgentApiKey('gemini');
|
|
44
|
+
if (!key) {
|
|
45
|
+
const err = new Error('Gemini API key is not configured');
|
|
46
|
+
err.code = 'MEDIA_LANE_UNAUTHENTICATED';
|
|
47
|
+
err.status = 401;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
return key;
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media studio surface (image / video generation).
|
|
3
|
+
*
|
|
4
|
+
* Single entry point for the session runtime: lane catalog, job control, and
|
|
5
|
+
* the on-disk asset store. Adapters are imported lazily by the job runner so a
|
|
6
|
+
* boot that never generates never pays for them.
|
|
7
|
+
*/
|
|
8
|
+
export { MEDIA_KINDS, listMediaLanes, getMediaLane } from './lanes.mjs';
|
|
9
|
+
export { startMediaJob, getMediaJob, listMediaJobs, cancelMediaJob } from './jobs.mjs';
|
|
10
|
+
export {
|
|
11
|
+
listMediaAssets,
|
|
12
|
+
readMediaAsset,
|
|
13
|
+
deleteMediaAsset,
|
|
14
|
+
mediaAssetPath,
|
|
15
|
+
openMediaAsset,
|
|
16
|
+
openMediaFolder,
|
|
17
|
+
} from './store.mjs';
|