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 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
- - **Unified Interface**: One request format (`provider`, `model`, `prompt`, `referenceImages`, etc.) maps automatically to the correct capability across providers.
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`: Lists providers, configured models, and supported capabilities.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-media-models",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral multimodal generation tools for Pi Coding Agent",
6
6
  "keywords": [
@@ -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 and model, call `media_models` first to see which providers have `configured: true` and which capabilities they support. Never invent model names.
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`, `model`, and `prompt` at minimum. Add optional parameters as needed.
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` | Exact model id as returned by `media_models` |
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).*
@@ -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
  }
@@ -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
- const configKey = request.providerOptions?.apiKey;
33
- const value = (typeof configKey === "string" ? configKey : undefined) ?? (this.envKey ? this.env[this.envKey] : undefined)
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
  }
@@ -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 type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
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-preview', ['video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.extend', 'video.native_audio']),
30
- makeModel(this.id, 'google', 'gemini-2.5-flash-preview-tts', ['speech.tts']),
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-3.5', ['audio.generate']),
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.key(request)
245
+ const key = this.keyFromOptions(options)
161
246
  const base = 'https://generativelanguage.googleapis.com/v1beta'
162
- return { url: `${base}/models/${encodeURIComponent(request.model)}:${method}`, headers: { 'x-goog-api-key': key }, operationsBase: base }
247
+ return { url: `${base}/models/${encodeURIComponent(model)}:${method}`, headers: { 'x-goog-api-key': key }, operationsBase: base }
163
248
  }
164
- const options = request.providerOptions;
165
- const configuredFile = typeof options?.credentialsFile === "string" ? options.credentialsFile : undefined
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 keyFilename = rawKeyFilename?.startsWith('file://') ? fileURLToPath(rawKeyFilename) : rawKeyFilename
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
- const base = `https://${location}-aiplatform.googleapis.com/v1`
178
- const resource = `projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/publishers/google/models/${encodeURIComponent(request.model)}`
179
- const authHeaders = await auth.getRequestHeaders(`${base}/${resource}:${method}`)
180
- const headers: Record<string, string> = {}
181
- for (const [key, value] of authHeaders.entries()) headers[key] = value
182
- return { url: `${base}/${resource}:${method}`, headers, operationsBase: base }
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
@@ -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
  }
@@ -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: project?.outputDir ?? global.outputDir,
80
+ outputDir,
66
81
  customProviders: project?.customProviders ?? global.customProviders ?? [],
67
- providerOptions: { ...(global.providerOptions ?? {}), ...(project?.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 (!adapter.envKey) return true
53
- if (this.env[adapter.envKey]) return true
54
- if (this.providerDefaults[adapter.id]?.apiKey) return true
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 = provider ? [this.adapters.get(provider)].filter((item): item is ProviderAdapter => Boolean(item)) : [...this.adapters.values()]
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
- async function executeRequest(request: MediaRequest, signal: AbortSignal | undefined, onUpdate: Parameters<Parameters<ExtensionAPI['registerTool']>[0]['execute']>[3], ctx: ExtensionContext) {
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, `Starting ${request.capability} with ${request.provider}/${request.model}…`)
41
- const result = await router.execute(request, {
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: 'List media providers and explicitly known/configured model capabilities. Does not infer custom capabilities from GET /models.',
53
- promptSnippet: 'List available media providers/models/capabilities before choosing a model',
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, _signal, _onUpdate, ctx) {
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
- const models = router.list(params.provider, capability)
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} [vendor=${model.vendor}; configured=${model.configured}] ${model.capabilities.join(', ')}`).join('\n')
65
- : 'No matching declared media models.'
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
  }