pi-media-models 0.1.5 → 0.1.7
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 +7 -5
- package/package.json +1 -1
- package/skills/pi-media/SKILL.md +4 -5
- package/src/adapters/atlas.ts +21 -1
- package/src/adapters/base.ts +6 -2
- package/src/adapters/google.ts +136 -16
- package/src/adapters/openai.ts +13 -1
- package/src/adapters/openrouter.ts +18 -1
- package/src/adapters/xai.ts +13 -1
- package/src/config.ts +40 -3
- package/src/router.ts +111 -6
- package/src/tools.ts +17 -10
- package/src/types.ts +12 -0
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ This extension seamlessly bridges Pi's reasoning capabilities with top-tier AI m
|
|
|
9
9
|
|
|
10
10
|
## ✨ Features
|
|
11
11
|
|
|
12
|
-
- **
|
|
12
|
+
- **Live Model Discovery**: Queries configured provider catalogs, performs lightweight access probes where supported, and marks the newest usable model as the default.
|
|
13
|
+
- **Unified Interface**: One request format (`provider`, optional `model`, `prompt`, `referenceImages`, etc.) maps automatically to the correct capability across providers. Omit `model` to use the newest discovered usable model.
|
|
13
14
|
- **Config-First Authentication**: Manage all API keys and credentials directly in a single `media-models.json` configuration file — no cluttered environment variables needed.
|
|
14
15
|
- **Auto-Download**: Output media (images, videos, audio) is automatically downloaded and saved to a local directory (`~/.pi/agent/media/outputs/`) using atomic `.part` renames. LLM context remains pristine and only receives local file paths.
|
|
15
16
|
- **Smart Input Resolution**: Pass local paths (`C:/...` or `/path/...`), file URIs (`file://`), standard URLs (`http(s)://`), or base64 (`data:...`). The router transparently handles multipart uploads, base64 encoding, or CDN pre-uploading (e.g., for `fal.ai`).
|
|
@@ -94,14 +95,13 @@ Create or edit `~/.pi/agent/media-models.json`:
|
|
|
94
95
|
},
|
|
95
96
|
"vertex": {
|
|
96
97
|
"credentialsFile": "/path/to/vertex-service-account.json",
|
|
97
|
-
"project": "my-gcp-project",
|
|
98
98
|
"location": "us-central1"
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
> **Note**: You only need to fill in the providers you plan to use. Unused providers can simply be omitted.
|
|
104
|
+
> **Note**: You only need to fill in the providers you plan to use. Unused providers can simply be omitted. Vertex reads `project_id` from the service-account JSON when `project` is omitted or still set to a placeholder such as `my-gcp-project`; an explicit real project ID remains supported.
|
|
105
105
|
|
|
106
106
|
### Custom OpenAI-Compatible Providers
|
|
107
107
|
|
|
@@ -148,13 +148,15 @@ You can connect any third-party or internal OpenAI-compatible media gateway by d
|
|
|
148
148
|
|
|
149
149
|
The extension registers exactly 6 unified tools for the reasoning agent:
|
|
150
150
|
|
|
151
|
-
1. `media_models`:
|
|
152
|
-
2. `image_generate`: Generate images from text, image, or multiple reference inputs.
|
|
151
|
+
1. `media_models`: Discovers credential-visible models, probes access where supported, reports availability, and marks the newest usable model as the default.
|
|
152
|
+
2. `image_generate`: Generate images from text, image, or multiple reference inputs. `model` is optional; omission selects the newest usable discovered model.
|
|
153
153
|
3. `image_edit`: Edit existing images (supports masks and multiple references).
|
|
154
154
|
4. `video_generate`: Generates, edits, or extends videos. Automatically maps inputs (`referenceImages`, `inputVideo`, `duration`, `generateAudio`, etc.) to the provider's exact capability.
|
|
155
155
|
5. `audio_generate`: Generate music or raw audio (separate from TTS).
|
|
156
156
|
6. `speech_generate`: Handle TTS (Text-to-Speech) and STT (Speech-to-Text).
|
|
157
157
|
|
|
158
|
+
Live catalog discovery is implemented for Google Gemini, Vertex AI Model Garden, OpenAI, xAI, Atlas, and OpenRouter. Providers without a reliable catalog API use their declared fallback candidates; those entries are labeled `source=built-in` and `availability=unknown` rather than being presented as verified.
|
|
159
|
+
|
|
158
160
|
## 🔒 Security & Privacy
|
|
159
161
|
|
|
160
162
|
- **No Key Logging**: API keys and Bearer tokens are redacted (`[REDACTED]`) from all error logs and HTTP outputs before being returned to the LLM.
|
package/package.json
CHANGED
package/skills/pi-media/SKILL.md
CHANGED
|
@@ -22,7 +22,7 @@ This skill activates the `pi-media-models` extension, which exposes six unified
|
|
|
22
22
|
|
|
23
23
|
### 1. Discover what is configured
|
|
24
24
|
|
|
25
|
-
When the user has not specified a provider
|
|
25
|
+
When the user has not specified a provider, call `media_models` first to see which providers have `configured: true` and which capabilities they support. Never invent model names. If the provider is known but the model is not, omit `model`; the media tool discovers models, probes access where supported, and selects the newest usable model.
|
|
26
26
|
|
|
27
27
|
```
|
|
28
28
|
media_models({ capability: "image.text_to_image" })
|
|
@@ -30,7 +30,7 @@ media_models({ capability: "image.text_to_image" })
|
|
|
30
30
|
|
|
31
31
|
### 2. Call the right tool
|
|
32
32
|
|
|
33
|
-
Pick the tool that matches the request, then pass `provider
|
|
33
|
+
Pick the tool that matches the request, then pass `provider` and `prompt` at minimum. Pass `model` only when a specific model is required; otherwise omit it for automatic discovery and selection. Add optional parameters as needed.
|
|
34
34
|
|
|
35
35
|
```
|
|
36
36
|
image_generate({
|
|
@@ -68,7 +68,7 @@ All generated media is automatically downloaded to `~/.pi/agent/media/outputs/`.
|
|
|
68
68
|
| Parameter | Description |
|
|
69
69
|
|---|---|
|
|
70
70
|
| `provider` | Provider id: `openai`, `gemini`, `vertex`, `xai`, `atlas`, `dashscope`, `qwencloud`, `fal`, `openrouter` |
|
|
71
|
-
| `model` |
|
|
71
|
+
| `model` | Optional exact model id as returned by `media_models`; omit to select the newest discovered usable model |
|
|
72
72
|
| `prompt` | Required. Describe the desired output |
|
|
73
73
|
| `aspectRatio` | `"16:9"`, `"9:16"`, `"1:1"`, `"4:3"`, etc. |
|
|
74
74
|
| `resolution` | `"1024x1024"`, `"720p"`, `"1080p"` |
|
|
@@ -97,13 +97,12 @@ All API keys and provider options are configured in `~/.pi/agent/media-models.js
|
|
|
97
97
|
"openrouter": { "apiKey": "sk-or-..." },
|
|
98
98
|
"vertex": {
|
|
99
99
|
"credentialsFile": "/path/to/service-account.json",
|
|
100
|
-
"project": "my-gcp-project",
|
|
101
100
|
"location": "us-central1"
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
103
|
}
|
|
105
104
|
```
|
|
106
105
|
|
|
107
|
-
Tell the user to edit `~/.pi/agent/media-models.json` directly to add or update API keys.
|
|
106
|
+
Tell the user to edit `~/.pi/agent/media-models.json` directly to add or update API keys. Vertex automatically reads `project_id` from the service-account JSON when `project` is omitted or still contains the default placeholder.
|
|
108
107
|
|
|
109
108
|
*(Environment variables such as `FAL_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY` are also checked as a fallback).*
|
package/src/adapters/atlas.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MediaError } from '../errors.js'
|
|
2
2
|
import { MediaJob, mapJobState } from '../media-job.js'
|
|
3
3
|
import { BaseAdapter, artifactsOrThrow, bearerHeaders, dataUris, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
4
|
-
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
4
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor, ModelDiscoveryContext } from '../types.js'
|
|
5
5
|
|
|
6
6
|
const ATLAS_CAPS: Capability[] = [
|
|
7
7
|
'image.text_to_image', 'image.image_to_image', 'image.edit',
|
|
@@ -23,6 +23,18 @@ export class AtlasAdapter extends BaseAdapter {
|
|
|
23
23
|
]
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
async discoverModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
27
|
+
const key = this.keyFromOptions(context.providerOptions)
|
|
28
|
+
const payload = await this.http.json<{ data?: Array<{ id?: string; owned_by?: string }> }>(`${this.baseUrl}/models`, {
|
|
29
|
+
headers: bearerHeaders(key), signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
30
|
+
})
|
|
31
|
+
return (payload.data ?? []).flatMap(entry => {
|
|
32
|
+
if (!entry.id) return []
|
|
33
|
+
const capabilities = atlasCapabilities(entry.id, this.models())
|
|
34
|
+
return capabilities.length ? [makeModel(this.id, entry.owned_by ?? 'multi-vendor', entry.id, capabilities)] : []
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
supports(capability: Capability): boolean { return ATLAS_CAPS.includes(capability) }
|
|
27
39
|
|
|
28
40
|
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
@@ -141,6 +153,14 @@ export class AtlasAdapter extends BaseAdapter {
|
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
function atlasCapabilities(id: string, declared: ModelDescriptor[]): Capability[] {
|
|
157
|
+
const known = declared.find(model => model.id === id)
|
|
158
|
+
if (known) return known.capabilities
|
|
159
|
+
if (/(?:image|dall-e|flux|ideogram)/i.test(id)) return ['image.text_to_image', 'image.image_to_image', 'image.edit']
|
|
160
|
+
if (/(?:video|veo|seedance|kling|sora)/i.test(id)) return ['video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.native_audio']
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
|
|
144
164
|
function stringId(payload: Record<string, unknown>): string | undefined {
|
|
145
165
|
return typeof payload.task_id === 'string' ? payload.task_id : typeof payload.id === 'string' ? payload.id : undefined
|
|
146
166
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -29,8 +29,12 @@ export abstract class BaseAdapter implements ProviderAdapter {
|
|
|
29
29
|
abstract execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult>
|
|
30
30
|
|
|
31
31
|
protected key(request: MediaRequest): string {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
return this.keyFromOptions(request.providerOptions)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
protected keyFromOptions(options?: JsonObject): string {
|
|
36
|
+
const configKey = options?.apiKey
|
|
37
|
+
const value = (typeof configKey === 'string' && configKey.trim() ? configKey.trim() : undefined) ?? (this.envKey ? this.env[this.envKey] : undefined)
|
|
34
38
|
if (!value) throw new MediaError('AUTH', `${this.envKey ?? `${this.id} API key`} is not set in environment or config`, { provider: this.id })
|
|
35
39
|
return value
|
|
36
40
|
}
|
package/src/adapters/google.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import { MediaError } from '../errors.js'
|
|
4
4
|
import { MediaJob } from '../media-job.js'
|
|
5
5
|
import { BaseAdapter, artifactsOrThrow, makeModel } from './base.js'
|
|
6
|
-
import
|
|
6
|
+
import { expandHomePath } from '../config.js'
|
|
7
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor, ModelDiscoveryContext } from '../types.js'
|
|
7
8
|
import type { AdapterDependencies } from './base.js'
|
|
8
9
|
|
|
9
10
|
const GOOGLE_CAPS: Capability[] = [
|
|
@@ -26,13 +27,42 @@ export class GoogleMediaAdapter extends BaseAdapter {
|
|
|
26
27
|
return [
|
|
27
28
|
makeModel(this.id, 'google', 'gemini-2.5-flash-image', ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']),
|
|
28
29
|
makeModel(this.id, 'google', 'imagen-4.0-generate-001', ['image.text_to_image']),
|
|
29
|
-
makeModel(this.id, 'google', 'veo-3.1-generate-
|
|
30
|
-
makeModel(this.id, 'google', 'gemini-2.5-flash-
|
|
30
|
+
makeModel(this.id, 'google', 'veo-3.1-generate-001', ['video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.extend', 'video.native_audio']),
|
|
31
|
+
makeModel(this.id, 'google', 'gemini-2.5-flash-tts', ['speech.tts']),
|
|
31
32
|
makeModel(this.id, 'google', 'gemini-2.5-flash', ['speech.stt']),
|
|
32
|
-
makeModel(this.id, 'google', 'lyria-
|
|
33
|
+
makeModel(this.id, 'google', 'lyria-002', ['audio.generate']),
|
|
33
34
|
]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
async discoverModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
38
|
+
return this.id === 'gemini' ? this.discoverGeminiModels(context) : this.discoverVertexModels(context)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async probeModel(model: ModelDescriptor, _capability: Capability, context: ModelDiscoveryContext): Promise<boolean | undefined> {
|
|
42
|
+
if (/^gemini-/i.test(model.id)) {
|
|
43
|
+
const { url, headers } = await this.requestForModel(model.id, context.providerOptions, 'countTokens')
|
|
44
|
+
await this.http.json<Record<string, unknown>>(url, {
|
|
45
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'availability check' }] }] }),
|
|
47
|
+
signal: context.signal, provider: this.id, secrets: this.discoverySecrets(context.providerOptions), timeoutMs: 30_000, retries: 0,
|
|
48
|
+
})
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
if (this.id !== 'vertex' || !/^imagen-/i.test(model.id)) return undefined
|
|
52
|
+
const { url, headers } = await this.requestForModel(model.id, context.providerOptions, 'predict')
|
|
53
|
+
try {
|
|
54
|
+
await this.http.json<Record<string, unknown>>(url, {
|
|
55
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ instances: [] }),
|
|
56
|
+
signal: context.signal, provider: this.id, timeoutMs: 30_000, retries: 0,
|
|
57
|
+
})
|
|
58
|
+
return true
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof MediaError && error.status === 400) return true
|
|
61
|
+
if (error instanceof MediaError && (error.status === 403 || error.status === 404)) return false
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
supports(capability: Capability): boolean { return GOOGLE_CAPS.includes(capability) }
|
|
37
67
|
|
|
38
68
|
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
@@ -155,31 +185,98 @@ export class GoogleMediaAdapter extends BaseAdapter {
|
|
|
155
185
|
: { bytesBase64Encoded: inline.data, mimeType: inline.mimeType }
|
|
156
186
|
}
|
|
157
187
|
|
|
188
|
+
private async discoverGeminiModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
189
|
+
const key = this.keyFromOptions(context.providerOptions)
|
|
190
|
+
const models: ModelDescriptor[] = []
|
|
191
|
+
let pageToken: string | undefined
|
|
192
|
+
do {
|
|
193
|
+
const url = new URL('https://generativelanguage.googleapis.com/v1beta/models')
|
|
194
|
+
url.searchParams.set('pageSize', '1000')
|
|
195
|
+
if (pageToken) url.searchParams.set('pageToken', pageToken)
|
|
196
|
+
const payload = await this.http.json<{ models?: Array<{ name?: string; supportedGenerationMethods?: string[] }>; nextPageToken?: string }>(url.toString(), {
|
|
197
|
+
headers: { 'x-goog-api-key': key }, signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
198
|
+
})
|
|
199
|
+
for (const entry of payload.models ?? []) {
|
|
200
|
+
const id = entry.name?.replace(/^models\//, '')
|
|
201
|
+
if (!id) continue
|
|
202
|
+
const capabilities = googleCapabilities(id, this.models())
|
|
203
|
+
if (capabilities.length) models.push({ ...makeModel(this.id, 'google', id, capabilities), availability: 'available' })
|
|
204
|
+
}
|
|
205
|
+
pageToken = payload.nextPageToken
|
|
206
|
+
} while (pageToken)
|
|
207
|
+
return uniqueModels(models)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async discoverVertexModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
211
|
+
const { auth, location } = await this.vertexSettings(context.providerOptions)
|
|
212
|
+
const models: ModelDescriptor[] = []
|
|
213
|
+
let pageToken: string | undefined
|
|
214
|
+
do {
|
|
215
|
+
const url = new URL(`https://${location}-aiplatform.googleapis.com/v1beta1/publishers/google/models`)
|
|
216
|
+
url.searchParams.set('pageSize', '100')
|
|
217
|
+
url.searchParams.set('listAllVersions', 'true')
|
|
218
|
+
if (pageToken) url.searchParams.set('pageToken', pageToken)
|
|
219
|
+
const authHeaders = await auth.getRequestHeaders(url.toString())
|
|
220
|
+
const headers: Record<string, string> = {}
|
|
221
|
+
for (const [key, value] of authHeaders.entries()) headers[key] = value
|
|
222
|
+
const payload = await this.http.json<{ publisherModels?: Array<{ name?: string; launchStage?: string }>; nextPageToken?: string }>(url.toString(), {
|
|
223
|
+
headers, signal: context.signal, provider: this.id, timeoutMs: 30_000,
|
|
224
|
+
})
|
|
225
|
+
for (const entry of payload.publisherModels ?? []) {
|
|
226
|
+
const id = entry.name?.split('/').pop()
|
|
227
|
+
if (!id) continue
|
|
228
|
+
const capabilities = googleCapabilities(id, this.models())
|
|
229
|
+
if (capabilities.length) models.push({
|
|
230
|
+
...makeModel(this.id, 'google', id, capabilities, entry.launchStage ? `Vertex Model Garden: ${entry.launchStage}` : undefined),
|
|
231
|
+
availability: 'unknown',
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
pageToken = payload.nextPageToken
|
|
235
|
+
} while (pageToken)
|
|
236
|
+
return uniqueModels(models)
|
|
237
|
+
}
|
|
238
|
+
|
|
158
239
|
private async modelRequest(request: MediaRequest, method: string): Promise<{ url: string; headers: Record<string, string>; operationsBase: string }> {
|
|
240
|
+
return this.requestForModel(request.model, request.providerOptions, method)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async requestForModel(model: string, options: JsonObject | undefined, method: string): Promise<{ url: string; headers: Record<string, string>; operationsBase: string }> {
|
|
159
244
|
if (this.id === 'gemini') {
|
|
160
|
-
const key = this.
|
|
245
|
+
const key = this.keyFromOptions(options)
|
|
161
246
|
const base = 'https://generativelanguage.googleapis.com/v1beta'
|
|
162
|
-
return { url: `${base}/models/${encodeURIComponent(
|
|
247
|
+
return { url: `${base}/models/${encodeURIComponent(model)}:${method}`, headers: { 'x-goog-api-key': key }, operationsBase: base }
|
|
163
248
|
}
|
|
164
|
-
const
|
|
165
|
-
const
|
|
249
|
+
const { auth, project, location } = await this.vertexSettings(options)
|
|
250
|
+
const base = `https://${location}-aiplatform.googleapis.com/v1`
|
|
251
|
+
const resource = `projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/publishers/google/models/${encodeURIComponent(model)}`
|
|
252
|
+
const url = `${base}/${resource}:${method}`
|
|
253
|
+
const authHeaders = await auth.getRequestHeaders(url)
|
|
254
|
+
const headers: Record<string, string> = {}
|
|
255
|
+
for (const [key, value] of authHeaders.entries()) headers[key] = value
|
|
256
|
+
return { url, headers, operationsBase: base }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async vertexSettings(options?: JsonObject): Promise<{ auth: GoogleAuth; project: string; location: string }> {
|
|
260
|
+
const configuredFile = typeof options?.credentialsFile === 'string' ? options.credentialsFile : undefined
|
|
166
261
|
const rawKeyFilename = configuredFile ?? this.env.VERTEX_CREDENTIALS_FILE ?? this.env.GOOGLE_APPLICATION_CREDENTIALS
|
|
167
|
-
const
|
|
262
|
+
const expandedKeyFile = expandHomePath(rawKeyFilename)
|
|
263
|
+
const keyFilename = expandedKeyFile?.startsWith('file://') ? fileURLToPath(expandedKeyFile) : expandedKeyFile
|
|
168
264
|
const auth = new GoogleAuth({
|
|
169
265
|
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
|
170
266
|
...(keyFilename ? { keyFilename } : {}),
|
|
171
267
|
})
|
|
172
|
-
const configuredProject = typeof options?.project === 'string' ? options.project : undefined
|
|
268
|
+
const configuredProject = typeof options?.project === 'string' && !isProjectPlaceholder(options.project) ? options.project : undefined
|
|
173
269
|
const project = configuredProject ?? this.env.GOOGLE_CLOUD_PROJECT ?? this.env.GCLOUD_PROJECT ?? await auth.getProjectId()
|
|
174
270
|
const configuredLocation = typeof options?.location === 'string' ? options.location : undefined
|
|
175
271
|
const location = configuredLocation ?? this.env.GOOGLE_CLOUD_LOCATION ?? 'us-central1'
|
|
176
272
|
if (!project) throw new MediaError('CONFIG', 'Vertex AI requires a project id from providerOptions.vertex.project, environment, ADC, or the credentials JSON', { provider: this.id })
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
273
|
+
return { auth, project, location }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private discoverySecrets(options?: JsonObject): string[] {
|
|
277
|
+
const configKey = typeof options?.apiKey === 'string' ? options.apiKey : undefined
|
|
278
|
+
const envKey = this.envKey && this.env[this.envKey] ? this.env[this.envKey] as string : undefined
|
|
279
|
+
return [configKey, envKey].filter((val): val is string => Boolean(val))
|
|
183
280
|
}
|
|
184
281
|
|
|
185
282
|
private secrets(request: MediaRequest): string[] {
|
|
@@ -189,6 +286,29 @@ export class GoogleMediaAdapter extends BaseAdapter {
|
|
|
189
286
|
}
|
|
190
287
|
}
|
|
191
288
|
|
|
289
|
+
function googleCapabilities(id: string, declared: ModelDescriptor[]): Capability[] {
|
|
290
|
+
const known = declared.find(model => model.id === id)
|
|
291
|
+
if (known) return known.capabilities
|
|
292
|
+
if (/^imagen-|^gemini-.*image(?:-|$)/i.test(id)) {
|
|
293
|
+
return /^imagen-/i.test(id)
|
|
294
|
+
? ['image.text_to_image']
|
|
295
|
+
: ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
296
|
+
}
|
|
297
|
+
if (/^veo-/i.test(id)) return ['video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.extend', 'video.native_audio']
|
|
298
|
+
if (/^lyria-/i.test(id)) return ['audio.generate']
|
|
299
|
+
if (/tts/i.test(id)) return ['speech.tts']
|
|
300
|
+
if (/transcribe/i.test(id)) return ['speech.stt']
|
|
301
|
+
return []
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function uniqueModels(models: ModelDescriptor[]): ModelDescriptor[] {
|
|
305
|
+
return [...new Map(models.map(model => [model.id, model])).values()]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isProjectPlaceholder(value: string): boolean {
|
|
309
|
+
return /^(?:my-gcp-project|your[-_ ]?(?:gcp[-_ ])?project(?:[-_ ]?id)?|<.*>)$/i.test(value.trim())
|
|
310
|
+
}
|
|
311
|
+
|
|
192
312
|
function findText(payload: unknown): string | undefined {
|
|
193
313
|
if (!payload || typeof payload !== 'object') return undefined
|
|
194
314
|
const candidates = (payload as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }).candidates
|
package/src/adapters/openai.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseAdapter, artifactsOrThrow, bearerHeaders, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
2
|
-
import type { AdapterContext, AdapterResult, Capability, MediaRequest, ModelDescriptor } from '../types.js'
|
|
2
|
+
import type { AdapterContext, AdapterResult, Capability, MediaRequest, ModelDescriptor, ModelDiscoveryContext } from '../types.js'
|
|
3
3
|
|
|
4
4
|
const IMAGE_CAPS: Capability[] = ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
5
5
|
const SPEECH_CAPS: Capability[] = ['speech.tts', 'speech.stt']
|
|
@@ -21,6 +21,18 @@ export class OpenAIAdapter extends BaseAdapter {
|
|
|
21
21
|
]
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
async discoverModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
25
|
+
const key = this.keyFromOptions(context.providerOptions)
|
|
26
|
+
const payload = await this.http.json<{ data?: Array<{ id?: string }> }>(`${this.baseUrl}/models`, {
|
|
27
|
+
headers: bearerHeaders(key), signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
28
|
+
})
|
|
29
|
+
return (payload.data ?? []).flatMap(entry => {
|
|
30
|
+
if (!entry.id) return []
|
|
31
|
+
const capabilities = [...IMAGE_CAPS, ...SPEECH_CAPS].filter(capability => this.supports(capability, entry.id as string))
|
|
32
|
+
return capabilities.length ? [makeModel(this.id, 'openai', entry.id, capabilities)] : []
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
supports(capability: Capability, model: string): boolean {
|
|
25
37
|
if (capability.startsWith('video.') || capability === 'audio.generate') return false
|
|
26
38
|
if (capability.startsWith('image.')) return /(?:gpt-image|dall-e)/i.test(model)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MediaError } from '../errors.js'
|
|
2
2
|
import { MediaJob, mapJobState } from '../media-job.js'
|
|
3
3
|
import { BaseAdapter, artifactsOrThrow, bearerHeaders, dataUris, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
4
|
-
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor, RemoteArtifact } from '../types.js'
|
|
4
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor, ModelDiscoveryContext, RemoteArtifact } from '../types.js'
|
|
5
5
|
|
|
6
6
|
const IMAGE_CAPS: Capability[] = ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
7
7
|
const VIDEO_CAPS: Capability[] = ['video.text_to_video', 'video.image_to_video', 'video.reference', 'video.native_audio']
|
|
@@ -20,6 +20,23 @@ export class OpenRouterAdapter extends BaseAdapter {
|
|
|
20
20
|
]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
async discoverModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
24
|
+
const key = this.keyFromOptions(context.providerOptions)
|
|
25
|
+
const payload = await this.http.json<{ data?: Array<{ id?: string; architecture?: { output_modalities?: string[] } }> }>(`${this.baseUrl}/models`, {
|
|
26
|
+
headers: bearerHeaders(key), signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
27
|
+
})
|
|
28
|
+
return (payload.data ?? []).flatMap(entry => {
|
|
29
|
+
if (!entry.id) return []
|
|
30
|
+
const outputs = entry.architecture?.output_modalities ?? []
|
|
31
|
+
const capabilities: Capability[] = [
|
|
32
|
+
...(outputs.includes('image') || /(?:image|flux|dall-e|ideogram)/i.test(entry.id) ? IMAGE_CAPS : []),
|
|
33
|
+
...(outputs.includes('video') || /(?:video|veo|seedance|kling|sora)/i.test(entry.id) ? VIDEO_CAPS : []),
|
|
34
|
+
...(outputs.includes('audio') && /tts|speech/i.test(entry.id) ? ['speech.tts' as const] : []),
|
|
35
|
+
]
|
|
36
|
+
return capabilities.length ? [makeModel(this.id, entry.id.split('/')[0] ?? 'multi-vendor', entry.id, [...new Set(capabilities)])] : []
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
supports(capability: Capability): boolean {
|
|
24
41
|
return IMAGE_CAPS.includes(capability) || VIDEO_CAPS.includes(capability) || capability === 'speech.tts'
|
|
25
42
|
}
|
package/src/adapters/xai.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MediaError } from '../errors.js'
|
|
2
2
|
import { MediaJob, mapJobState } from '../media-job.js'
|
|
3
3
|
import { BaseAdapter, artifactsOrThrow, bearerHeaders, dataUris, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
4
|
-
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
4
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor, ModelDiscoveryContext } from '../types.js'
|
|
5
5
|
|
|
6
6
|
const IMAGE_CAPS: Capability[] = ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
7
7
|
const VIDEO_CAPS: Capability[] = ['video.text_to_video', 'video.image_to_video', 'video.reference', 'video.edit', 'video.extend', 'video.native_audio']
|
|
@@ -20,6 +20,18 @@ export class XAIAdapter extends BaseAdapter {
|
|
|
20
20
|
]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
async discoverModels(context: ModelDiscoveryContext): Promise<ModelDescriptor[]> {
|
|
24
|
+
const key = this.keyFromOptions(context.providerOptions)
|
|
25
|
+
const payload = await this.http.json<{ data?: Array<{ id?: string }> }>(`${this.baseUrl}/models`, {
|
|
26
|
+
headers: bearerHeaders(key), signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
27
|
+
})
|
|
28
|
+
return (payload.data ?? []).flatMap(entry => {
|
|
29
|
+
if (!entry.id) return []
|
|
30
|
+
const capabilities = [...IMAGE_CAPS, ...VIDEO_CAPS].filter(capability => this.supports(capability, entry.id as string))
|
|
31
|
+
return capabilities.length ? [makeModel(this.id, 'xai', entry.id, capabilities)] : []
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
supports(capability: Capability, model: string): boolean {
|
|
24
36
|
if (capability.startsWith('image.')) return /image/i.test(model)
|
|
25
37
|
if (capability.startsWith('video.')) {
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { CAPABILITIES, type Capability, type JsonObject } from './types.js'
|
|
5
6
|
import { MediaError } from './errors.js'
|
|
6
7
|
|
|
8
|
+
export function expandHomePath(rawPath: string | undefined): string | undefined {
|
|
9
|
+
if (!rawPath) return undefined
|
|
10
|
+
if (rawPath === '~') return homedir()
|
|
11
|
+
if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
|
|
12
|
+
return join(homedir(), rawPath.slice(2))
|
|
13
|
+
}
|
|
14
|
+
return rawPath
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
export interface CustomAsyncConfig {
|
|
8
18
|
idPath: string
|
|
9
19
|
statusPath: string
|
|
@@ -61,15 +71,42 @@ export async function loadMediaConfig(cwd: string, allowProjectConfig: boolean):
|
|
|
61
71
|
const globalPath = join(homedir(), '.pi', 'agent', 'media-models.json')
|
|
62
72
|
const global = await parseFile(globalPath) ?? EMPTY_CONFIG
|
|
63
73
|
const project = allowProjectConfig ? await parseFile(join(cwd, '.pi', 'media-models.json')) : undefined
|
|
74
|
+
const rawOutputDir = project?.outputDir ?? global.outputDir
|
|
75
|
+
const expandedOutputDir = expandHomePath(rawOutputDir)
|
|
76
|
+
const outputDir = expandedOutputDir ? resolve(cwd, expandedOutputDir) : undefined
|
|
77
|
+
const providerOptions = { ...(global.providerOptions ?? {}), ...(project?.providerOptions ?? {}) }
|
|
78
|
+
if (providerOptions.vertex) providerOptions.vertex = await resolveVertexProjectOptions(providerOptions.vertex)
|
|
64
79
|
const merged: MediaConfig = {
|
|
65
|
-
outputDir
|
|
80
|
+
outputDir,
|
|
66
81
|
customProviders: project?.customProviders ?? global.customProviders ?? [],
|
|
67
|
-
providerOptions
|
|
82
|
+
providerOptions,
|
|
68
83
|
}
|
|
69
84
|
validateCustomProviders(merged.customProviders)
|
|
70
85
|
return merged
|
|
71
86
|
}
|
|
72
87
|
|
|
88
|
+
export async function resolveVertexProjectOptions(options: JsonObject): Promise<JsonObject> {
|
|
89
|
+
const configuredProject = typeof options.project === 'string' ? options.project.trim() : ''
|
|
90
|
+
if (configuredProject && !isProjectPlaceholder(configuredProject)) return options
|
|
91
|
+
if (typeof options.credentialsFile !== 'string' || !options.credentialsFile.trim()) return options
|
|
92
|
+
const expanded = expandHomePath(options.credentialsFile)
|
|
93
|
+
const credentialsFile = expanded?.startsWith('file://') ? fileURLToPath(expanded) : expanded
|
|
94
|
+
if (!credentialsFile) return options
|
|
95
|
+
try {
|
|
96
|
+
const credentials = JSON.parse(await readFile(credentialsFile, 'utf8')) as { project_id?: unknown }
|
|
97
|
+
return typeof credentials.project_id === 'string' && credentials.project_id.trim()
|
|
98
|
+
? { ...options, project: credentials.project_id.trim() }
|
|
99
|
+
: options
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return options
|
|
102
|
+
throw new MediaError('CONFIG', `Invalid Vertex credentials ${credentialsFile}: ${error instanceof Error ? error.message : String(error)}`, { cause: error })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isProjectPlaceholder(value: string): boolean {
|
|
107
|
+
return /^(?:my-gcp-project|your[-_ ]?(?:gcp[-_ ])?project(?:[-_ ]?id)?|<.*>)$/i.test(value)
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
function validateCustomProviders(providers: CustomProviderConfig[]): void {
|
|
74
111
|
const ids = new Set<string>()
|
|
75
112
|
for (const provider of providers) {
|
package/src/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { MediaConfig } from './config.js'
|
|
|
3
3
|
import { asMediaError, MediaError } from './errors.js'
|
|
4
4
|
import { HttpClient, type FetchLike } from './http.js'
|
|
5
5
|
import { InputResolver } from './input.js'
|
|
6
|
-
import type { AdapterContext, Capability, MediaRequest, ModelDescriptor, NormalizedResult, ProviderAdapter } from './types.js'
|
|
6
|
+
import type { AdapterContext, Capability, MediaRequest, ModelDescriptor, ModelDiscoveryContext, NormalizedResult, ProviderAdapter } from './types.js'
|
|
7
7
|
import { AtlasAdapter } from './adapters/atlas.js'
|
|
8
8
|
import { CustomOpenAICompatibleAdapter } from './adapters/custom.js'
|
|
9
9
|
import { DashScopeAdapter } from './adapters/dashscope.js'
|
|
@@ -49,17 +49,87 @@ export class CapabilityRouter {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
private isConfigured(adapter: ProviderAdapter): boolean {
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
if (adapter.id === 'vertex') {
|
|
53
|
+
const vertexOpts = this.providerDefaults['vertex']
|
|
54
|
+
if (typeof vertexOpts?.credentialsFile === 'string' && vertexOpts.credentialsFile.trim()) return true
|
|
55
|
+
if (this.env.GOOGLE_APPLICATION_CREDENTIALS || this.env.VERTEX_CREDENTIALS_FILE) return true
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
const configKey = this.providerDefaults[adapter.id]?.apiKey
|
|
59
|
+
if (typeof configKey === 'string' && configKey.trim()) return true
|
|
60
|
+
if (adapter.envKey && this.env[adapter.envKey]) return true
|
|
55
61
|
return false
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
list(provider?: string, capability?: Capability): Array<ModelDescriptor & { configured: boolean }> {
|
|
59
|
-
const adapters =
|
|
65
|
+
const adapters = this.selectedAdapters(provider)
|
|
60
66
|
return adapters.flatMap(adapter => adapter.models()
|
|
61
67
|
.filter(model => !capability || model.capabilities.includes(capability))
|
|
62
|
-
.map(model => ({ ...model, configured: this.isConfigured(adapter) })))
|
|
68
|
+
.map(model => ({ ...model, configured: this.isConfigured(adapter), availability: 'unknown' as const, source: 'built-in' as const })))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async discover(provider?: string, capability?: Capability, context: AdapterContext & { probe?: boolean } = {}): Promise<Array<ModelDescriptor & { configured: boolean }>> {
|
|
72
|
+
const groups = await Promise.all(this.selectedAdapters(provider).map(async adapter => {
|
|
73
|
+
const configured = this.isConfigured(adapter)
|
|
74
|
+
const discoveryContext: ModelDiscoveryContext = {
|
|
75
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
76
|
+
providerOptions: this.providerDefaults[adapter.id] ?? {},
|
|
77
|
+
}
|
|
78
|
+
let models: ModelDescriptor[]
|
|
79
|
+
if (!configured || !adapter.discoverModels) {
|
|
80
|
+
models = adapter.models().map(model => ({ ...model, availability: 'unknown', source: 'built-in' }))
|
|
81
|
+
} else {
|
|
82
|
+
try {
|
|
83
|
+
models = (await adapter.discoverModels(discoveryContext)).map(model => ({
|
|
84
|
+
...model,
|
|
85
|
+
availability: model.availability ?? 'available',
|
|
86
|
+
source: 'discovered',
|
|
87
|
+
}))
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const normalized = asMediaError(error, adapter.id)
|
|
90
|
+
const message = normalized.status ? `HTTP ${normalized.status}` : normalized.message.split('\n', 1)[0]
|
|
91
|
+
models = adapter.models().map(model => ({
|
|
92
|
+
...model,
|
|
93
|
+
availability: 'unknown',
|
|
94
|
+
source: 'built-in',
|
|
95
|
+
notes: [model.notes, `Discovery failed: ${message}`].filter(Boolean).join('; '),
|
|
96
|
+
}))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
models = models.filter(model => !capability || model.capabilities.includes(capability))
|
|
100
|
+
if (configured && context.probe !== false && adapter.probeModel) {
|
|
101
|
+
models = await Promise.all(models.map(async model => {
|
|
102
|
+
try {
|
|
103
|
+
const probeCapability = capability ?? model.capabilities[0]
|
|
104
|
+
const available = probeCapability ? await adapter.probeModel?.(model, probeCapability, discoveryContext) : undefined
|
|
105
|
+
return available === undefined ? model : { ...model, availability: available ? 'available' as const : 'unavailable' as const }
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const normalized = asMediaError(error, adapter.id)
|
|
108
|
+
const unavailable = normalized.status === 400 || normalized.status === 403 || normalized.status === 404
|
|
109
|
+
const probeMessage = unavailable
|
|
110
|
+
? `Probe failed: HTTP ${normalized.status} (model unavailable to the configured project/location)`
|
|
111
|
+
: `Probe failed: ${normalized.message.split('\n', 1)[0]}`
|
|
112
|
+
return {
|
|
113
|
+
...model,
|
|
114
|
+
availability: unavailable ? 'unavailable' as const : 'unknown' as const,
|
|
115
|
+
notes: [model.notes, probeMessage].filter(Boolean).join('; '),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}))
|
|
119
|
+
}
|
|
120
|
+
const sorted = models.sort(compareModels)
|
|
121
|
+
const defaultIndex = sorted.findIndex(model => model.availability !== 'unavailable' && !isPlaceholderModel(model.id))
|
|
122
|
+
return sorted.map((model, index) => ({ ...model, configured, ...(index === defaultIndex ? { isDefault: true } : {}) }))
|
|
123
|
+
}))
|
|
124
|
+
return groups.flat()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async defaultModel(provider: string, capability: Capability, context: AdapterContext = {}): Promise<string> {
|
|
128
|
+
if (!this.adapters.has(provider)) throw new MediaError('CONFIG', `Unknown media provider: ${provider}`)
|
|
129
|
+
const models = await this.discover(provider, capability, { ...context, probe: true })
|
|
130
|
+
const selected = models.find(model => model.isDefault)
|
|
131
|
+
if (!selected) throw new MediaError('CONFIG', `No usable ${capability} model was discovered for ${provider}; specify a model explicitly`, { provider })
|
|
132
|
+
return selected.id
|
|
63
133
|
}
|
|
64
134
|
|
|
65
135
|
providers(): Array<{ id: string; name: string; configured: boolean; envKey?: string }> {
|
|
@@ -71,6 +141,10 @@ export class CapabilityRouter {
|
|
|
71
141
|
}))
|
|
72
142
|
}
|
|
73
143
|
|
|
144
|
+
private selectedAdapters(provider?: string): ProviderAdapter[] {
|
|
145
|
+
return provider ? [this.adapters.get(provider)].filter((item): item is ProviderAdapter => Boolean(item)) : [...this.adapters.values()]
|
|
146
|
+
}
|
|
147
|
+
|
|
74
148
|
async execute(request: MediaRequest, context: AdapterContext = {}): Promise<NormalizedResult> {
|
|
75
149
|
const adapter = this.adapters.get(request.provider)
|
|
76
150
|
if (!adapter) throw new MediaError('CONFIG', `Unknown media provider: ${request.provider}`)
|
|
@@ -93,3 +167,34 @@ export class CapabilityRouter {
|
|
|
93
167
|
}
|
|
94
168
|
}
|
|
95
169
|
}
|
|
170
|
+
|
|
171
|
+
function compareModels(left: ModelDescriptor, right: ModelDescriptor): number {
|
|
172
|
+
const availability = { available: 2, unknown: 1, unavailable: 0 }
|
|
173
|
+
const availabilityDifference = availability[right.availability ?? 'unknown'] - availability[left.availability ?? 'unknown']
|
|
174
|
+
if (availabilityDifference) return availabilityDifference
|
|
175
|
+
if (left.source === 'built-in' && right.source === 'built-in') return 0
|
|
176
|
+
const familyDifference = modelFamilyPriority(right.id) - modelFamilyPriority(left.id)
|
|
177
|
+
if (familyDifference) return familyDifference
|
|
178
|
+
const leftVersion = modelVersion(left.id)
|
|
179
|
+
const rightVersion = modelVersion(right.id)
|
|
180
|
+
for (let index = 0; index < Math.max(leftVersion.length, rightVersion.length); index += 1) {
|
|
181
|
+
const difference = (rightVersion[index] ?? 0) - (leftVersion[index] ?? 0)
|
|
182
|
+
if (difference) return difference
|
|
183
|
+
}
|
|
184
|
+
const previewDifference = Number(/(?:preview|experimental)/i.test(left.id)) - Number(/(?:preview|experimental)/i.test(right.id))
|
|
185
|
+
return previewDifference || right.id.localeCompare(left.id)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function modelFamilyPriority(id: string): number {
|
|
189
|
+
if (/gpt-image/i.test(id)) return 2
|
|
190
|
+
if (/dall-e/i.test(id)) return 1
|
|
191
|
+
return 0
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function modelVersion(id: string): number[] {
|
|
195
|
+
return [...id.matchAll(/\d+/g)].map(match => Number(match[0]))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPlaceholderModel(id: string): boolean {
|
|
199
|
+
return /^<.*>$/.test(id.trim())
|
|
200
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Capability, JsonObject, MediaRequest, NormalizedResult } from './t
|
|
|
7
7
|
|
|
8
8
|
const providerModel = {
|
|
9
9
|
provider: Type.String({ description: 'Provider id: openrouter, fal, dashscope, qwencloud, openai, gemini, vertex, xai, atlas, or an explicitly configured custom provider' }),
|
|
10
|
-
model: Type.String({ description: 'Exact provider model id or fal endpoint slug' }),
|
|
10
|
+
model: Type.Optional(Type.String({ description: 'Exact provider model id or fal endpoint slug. Omit to auto-select the newest discovered usable model.' })),
|
|
11
11
|
}
|
|
12
12
|
const providerOptions = Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: 'Provider-native options. These override normalized mappings; never include API keys.' }))
|
|
13
13
|
const commonOutput = {
|
|
@@ -35,10 +35,15 @@ function concise(result: NormalizedResult): string {
|
|
|
35
35
|
return lines.join('\n')
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
type ToolMediaRequest = Omit<MediaRequest, 'model'> & { model?: string }
|
|
39
|
+
|
|
40
|
+
async function executeRequest(request: ToolMediaRequest, signal: AbortSignal | undefined, onUpdate: Parameters<Parameters<ExtensionAPI['registerTool']>[0]['execute']>[3], ctx: ExtensionContext) {
|
|
39
41
|
const router = await routerFor(ctx)
|
|
40
|
-
progress(onUpdate as never, `
|
|
41
|
-
const
|
|
42
|
+
if (!request.model) progress(onUpdate as never, `Discovering the newest usable ${request.capability} model for ${request.provider}…`)
|
|
43
|
+
const model = request.model?.trim() || await router.defaultModel(request.provider, request.capability, signal ? { signal } : {})
|
|
44
|
+
const resolvedRequest: MediaRequest = { ...request, model }
|
|
45
|
+
progress(onUpdate as never, `Starting ${request.capability} with ${request.provider}/${model}…`)
|
|
46
|
+
const result = await router.execute(resolvedRequest, {
|
|
42
47
|
...(signal ? { signal } : {}),
|
|
43
48
|
onProgress: message => progress(onUpdate as never, message),
|
|
44
49
|
})
|
|
@@ -49,20 +54,22 @@ export function registerMediaTools(pi: ExtensionAPI): void {
|
|
|
49
54
|
pi.registerTool({
|
|
50
55
|
name: 'media_models',
|
|
51
56
|
label: 'Media Models',
|
|
52
|
-
description: '
|
|
53
|
-
promptSnippet: '
|
|
57
|
+
description: 'Discover models visible to configured provider credentials, optionally probe capability access, and mark the newest usable model as default. Falls back to built-in candidates when a provider has no discovery API.',
|
|
58
|
+
promptSnippet: 'Discover available media providers/models/capabilities before choosing a model',
|
|
54
59
|
parameters: Type.Object({
|
|
55
60
|
provider: Type.Optional(Type.String()),
|
|
56
61
|
capability: Type.Optional(Type.String()),
|
|
62
|
+
probe: Type.Optional(Type.Boolean({ description: 'Run lightweight capability probes when supported. Defaults to true.' })),
|
|
57
63
|
}),
|
|
58
|
-
async execute(_id, params,
|
|
64
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
59
65
|
const router = await routerFor(ctx)
|
|
60
66
|
const capability = params.capability as Capability | undefined
|
|
61
|
-
|
|
67
|
+
progress(onUpdate as never, 'Discovering models from configured providers…')
|
|
68
|
+
const models = await router.discover(params.provider, capability, { ...(signal ? { signal } : {}), probe: params.probe !== false })
|
|
62
69
|
const providers = router.providers()
|
|
63
70
|
const text = models.length
|
|
64
|
-
? models.map(model => `${model.provider}/${model.id} [
|
|
65
|
-
: 'No matching
|
|
71
|
+
? models.map(model => `${model.provider}/${model.id} [${model.availability}; source=${model.source}; configured=${model.configured}${model.isDefault ? '; default=newest' : ''}] ${model.capabilities.join(', ')}${model.notes ? ` · ${model.notes}` : ''}`).join('\n')
|
|
72
|
+
: 'No matching media models were discovered.'
|
|
66
73
|
return { content: [{ type: 'text', text }], details: { providers, models } }
|
|
67
74
|
},
|
|
68
75
|
})
|
package/src/types.ts
CHANGED
|
@@ -123,12 +123,18 @@ export interface MediaJobOptions<T> {
|
|
|
123
123
|
onProgress?: (status: JobStatus<T>) => void
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
export type ModelAvailability = 'available' | 'unavailable' | 'unknown'
|
|
127
|
+
export type ModelSource = 'discovered' | 'built-in'
|
|
128
|
+
|
|
126
129
|
export interface ModelDescriptor {
|
|
127
130
|
provider: string
|
|
128
131
|
vendor: string
|
|
129
132
|
id: string
|
|
130
133
|
capabilities: Capability[]
|
|
131
134
|
notes?: string
|
|
135
|
+
availability?: ModelAvailability
|
|
136
|
+
source?: ModelSource
|
|
137
|
+
isDefault?: boolean
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
export interface AdapterContext {
|
|
@@ -136,11 +142,17 @@ export interface AdapterContext {
|
|
|
136
142
|
onProgress?: (message: string) => void
|
|
137
143
|
}
|
|
138
144
|
|
|
145
|
+
export interface ModelDiscoveryContext extends AdapterContext {
|
|
146
|
+
providerOptions?: JsonObject
|
|
147
|
+
}
|
|
148
|
+
|
|
139
149
|
export interface ProviderAdapter {
|
|
140
150
|
readonly id: string
|
|
141
151
|
readonly displayName: string
|
|
142
152
|
readonly envKey?: string
|
|
143
153
|
models(): ModelDescriptor[]
|
|
154
|
+
discoverModels?(context: ModelDiscoveryContext): Promise<ModelDescriptor[]>
|
|
155
|
+
probeModel?(model: ModelDescriptor, capability: Capability, context: ModelDiscoveryContext): Promise<boolean | undefined>
|
|
144
156
|
supports(capability: Capability, model: string): boolean
|
|
145
157
|
execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult>
|
|
146
158
|
}
|