# Universal Custom Platforms

## Selection Rule

Use `universal` only when documented upstream requirements cannot be represented by an existing standard client. Prefer:

1. `openai` for OpenAI-compatible chat, Responses, embeddings, reranks, image generation, or image editing.
2. `gemini` for Gemini `generateContent` contracts.
3. A provider-specific client such as `volces`, `keling`, or `minimax` when implemented.
4. `universal` for non-standard auth, submit/poll lifecycles, payload shaping, or response mapping.

Set the provider catalog `providerType` or platform `config.specType` explicitly to `universal`. An unknown type may fall back to OpenAI behavior.

Do not assume a new platform-model binding can be staged with `enabled: false`; the current upsert path enables it. Isolate tests with the platform status or a dedicated non-production deployment.

## Platform Configuration

Gateway directly recognizes these universal settings in the platform `config` object:

- `specType`
- `submitPath`
- `getTaskURL`
- `pollIntervalMs`
- `pollTimeoutMs` or `timeoutMs`
- `skipParamNormalization`
- `customPreprocessScript`
- `customGetParamsScript`
- `customSubmitScript`
- `customPollScript`

Do not invent additional enforcement fields. Other config keys are only exposed as data through `context.env` unless a custom script explicitly reads and enforces them.

```json
{
  "specType": "universal",
  "submitPath": "/video/generations",
  "getTaskURL": "https://provider.example/tasks/{upstream_task_id}",
  "pollIntervalMs": 2000,
  "pollTimeoutMs": 600000,
  "skipParamNormalization": false,
  "customGetParamsScript": {
    "video_generate": "function getGenerateParams(params, context) { return { model: context.options.providerModelName, prompt: params.prompt }; }"
  },
  "customSubmitScript": {
    "video_generate": "async function submitTask(payload, context) { const response = await got.post(context.createRequestURL('/video/generations'), { json: payload, headers: { Authorization: 'Bearer ' + context.authValues.apiKey } }).json(); const taskId = String(response.id || ''); if (!/^[A-Za-z0-9._:-]{1,200}$/.test(taskId)) return { status: 'failed', code: 'invalid_response', message: 'invalid upstream task id' }; return { status: 'submitted', task_id: taskId }; }"
  },
  "customPollScript": {
    "video_generate": "async function pollTask(taskId, context) { if (!/^[A-Za-z0-9._:-]{1,200}$/.test(taskId)) return { status: 'failed', code: 'invalid_response', message: 'invalid upstream task id' }; const response = await got.get(context.resolveGetTaskURL(taskId), { headers: { Authorization: 'Bearer ' + context.authValues.apiKey } }).json(); if (response.status === 'done') { const resultUrl = String(response.url || ''); if (!/^https:\\/\\/cdn\\.video\\.example(?:\\/|$)/.test(resultUrl)) return { status: 'failed', code: 'invalid_response', message: 'untrusted result URL' }; return { status: 'succeeded', data: [{ url: resultUrl }] }; } if (response.status === 'failed') return { status: 'failed', code: 'provider_failed', message: String(response.message || 'provider task failed').slice(0, 300) }; if (response.status === 'queued' || response.status === 'processing' || response.status === 'running') return { status: 'processing' }; return { status: 'failed', code: 'invalid_response', message: 'unknown upstream status' }; }"
  }
}
```

Script values may be a string applied to all model types or an object keyed by model type with optional `common`.

## Script Contracts

- Preprocess: `(params, type, context)`; return an object merged into normalized parameters. Preferred names include `preprocessParams`, `preprocess`, `main`, and `handler`.
- Get params: `(params, context)`; return the upstream payload object. Preferred names include `getGenerateParams`, `getParams`, `main`, and `handler`.
- Submit: `(payload, context)`; return final success data or an asynchronous task ID. Preferred names include `submitTask`, `submitParams`, `submit`, `main`, and `handler`.
- Poll: `(upstreamTaskID, context)`; return processing, success, or failure state. Preferred names include `pollTask`, `poll`, `main`, and `handler`.

The context includes `baseURL`, `getTaskURL`, `authValues`, `headers`, `payload`, `type`, `options`, `env`, `candidate`, `createRequestURL`, and `resolveGetTaskURL`. The runtime also exposes `fetch`, `got`, and `FormData`.

If a result URL must be restricted, implement the HTTPS and host allowlist check inside the submit or poll script before returning `data`. Merely adding an `allowedResultUrlPrefixes`-style config field has no built-in effect. Do not convert HTTP failures into `processing` unless the upstream documentation proves that retrying the poll is safe.

Do not assume Node.js or browser globals exist. The runtime does not install `URL`, `Buffer`, `process`, `require`, or Node modules. Use the provided context helpers, `fetch`, `got`, `FormData`, and standard ECMAScript string or regular-expression checks.

## Result Shapes

Synchronous success:

```json
{"status":"succeeded","data":[{"url":"https://provider.example/result.png"}]}
```

Asynchronous submit:

```json
{"status":"submitted","task_id":"remote-task-id"}
```

Poll success or failure:

```json
{"status":"succeeded","data":[{"url":"https://provider.example/result.mp4"}]}
```

```json
{"status":"failed","code":"provider_failed","message":"upstream rejected request"}
```

Never log, return, or retain `authValues`, full raw responses, base64 media, Buffers, HTTP response objects, or entire request snapshots. Return only the fields required for routing and normalized output.
