@zeldrisho/pi-opencode-free 0.0.0-stage → 0.1.0
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/package.json +57 -3
- package/src/fingerprint.ts +249 -0
- package/src/index.ts +148 -0
- package/src/models.ts +462 -0
- package/src/request.ts +121 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add the OpenCode Free provider for using OpenCode's free-tier models in Pi without an OpenCode installation or API key.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Discover free models through Pi's native OpenCode catalog from pi.dev instead of models.dev, preserving Pi's capabilities and compatibility metadata.
|
|
19
|
+
|
|
20
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-opencode-free-v0.1.0...HEAD
|
|
21
|
+
[0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-opencode-free-v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zeldrisho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zeldrisho/pi-opencode-free
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use OpenCode's free-tier models in Pi. OpenCode does not need to be installed, and no account or API key is required.
|
|
4
|
+
|
|
5
|
+
The extension adds an **OpenCode Free** provider that talks directly to OpenCode's OpenAI-compatible inference endpoint. It uses Pi's normal system prompt and tools; it does not launch OpenCode or import OpenCode's prompt.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@zeldrisho/pi-opencode-free
|
|
11
|
+
|
|
12
|
+
# Project:
|
|
13
|
+
pi install -l npm:@zeldrisho/pi-opencode-free
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Restart Pi after installing. Use `pi config` to enable or disable the extension, and `pi list` to see configured packages and their install paths.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
1. Run `/model`.
|
|
21
|
+
2. Type `opencode-free` to filter the list.
|
|
22
|
+
3. Select a model tagged `[opencode-free]`.
|
|
23
|
+
|
|
24
|
+
The picker may say "Only showing models from configured providers. Use /login to add providers." You do not need to run `/login`; OpenCode Free is already configured.
|
|
25
|
+
|
|
26
|
+
The model list is synced from OpenCode and matched against Pi's native OpenCode catalog from [pi.dev](https://pi.dev/models), the same catalog Pi uses for model capabilities and compatibility. Models appear and disappear as OpenCode's free catalog changes. After a successful sync, the catalog is cached for later sessions. If Pi starts without network access or the sync fails, it uses the last synced catalog, or the free models in Pi's bundled OpenCode catalog on a first offline run. If the list looks stale, restart Pi to trigger another sync.
|
|
27
|
+
|
|
28
|
+
## Troubleshooting
|
|
29
|
+
|
|
30
|
+
If **OpenCode Free** does not appear in `/model`:
|
|
31
|
+
|
|
32
|
+
- Confirm the package is installed with `pi list`.
|
|
33
|
+
- Confirm it is enabled with `pi config`.
|
|
34
|
+
- Restart Pi after installing or enabling it.
|
|
35
|
+
|
|
36
|
+
If the provider appears but no models are listed, check that this machine can reach `https://opencode.ai` and `https://pi.dev`. Discovery needs both; when it cannot run, the extension falls back to the cached catalog or Pi's bundled OpenCode catalog.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
| Variable | Default | Description |
|
|
41
|
+
| --------------------------- | ---------------------------------- | ---------------------------------------------------------------------- |
|
|
42
|
+
| `OPENCODE_FREE_API_KEY` | `public` | Bearer token sent to the inference endpoint. |
|
|
43
|
+
| `OPENCODE_FREE_BASE_URL` | `https://opencode.ai/inference/v1` | OpenAI-compatible base URL. Point this at a compatible proxy. |
|
|
44
|
+
| `OPENCODE_API_KEY` | unset | Fallback bearer token when `OPENCODE_FREE_API_KEY` is unset. |
|
|
45
|
+
| `OPENCODE_FREE_CLI_VERSION` | compatibility version | Optional client version reported to the endpoint, for example `2.1.3`. |
|
|
46
|
+
|
|
47
|
+
For example, to route through a local proxy:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export OPENCODE_FREE_BASE_URL=http://127.0.0.1:8080/v1
|
|
51
|
+
pi
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The proxy must expose an OpenAI-compatible `/chat/completions` endpoint and should return the current free models from `/models`.
|
|
55
|
+
|
|
56
|
+
## Notes
|
|
57
|
+
|
|
58
|
+
- Free-tier availability, rate limits, and model names can change at any time.
|
|
59
|
+
- This package is unofficial and is not affiliated with OpenCode.
|
|
60
|
+
- Inference and `/models` requests use the configured base URL. Model catalog discovery always contacts `pi.dev` at a fixed URL, so `OPENCODE_FREE_BASE_URL` does not redirect it. Set `OPENCODE_FREE_BASE_URL` only to a proxy you trust, because that proxy receives your prompts and the bearer token.
|
|
61
|
+
|
|
62
|
+
## Uninstall
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pi remove npm:@zeldrisho/pi-opencode-free
|
|
66
|
+
|
|
67
|
+
# Project:
|
|
68
|
+
pi remove -l npm:@zeldrisho/pi-opencode-free
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-opencode-free",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi provider extension for OpenCode's free-tier models",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-free",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-package"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/zeldrisho/pi-packages/tree/main/packages/pi-opencode-free#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/zeldrisho/pi-packages/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "zeldrisho",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/zeldrisho/pi-packages.git",
|
|
22
|
+
"directory": "packages/pi-opencode-free"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
35
|
+
"typescript": "^7.0.2",
|
|
36
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
37
|
+
"vite-plus": "1.3.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "*",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=24"
|
|
45
|
+
},
|
|
46
|
+
"pi": {
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./src/index.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"check": "vp check",
|
|
53
|
+
"test": "vp test",
|
|
54
|
+
"test:watch": "vp test --watch",
|
|
55
|
+
"lint": "vp lint",
|
|
56
|
+
"lint:fix": "vp lint --fix",
|
|
57
|
+
"format": "vp fmt --write",
|
|
58
|
+
"typecheck": "vp check --no-fmt --no-lint"
|
|
59
|
+
}
|
|
6
60
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type { ProviderHeaders } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Synthetic major-2 client version used when no override is configured.
|
|
6
|
+
*
|
|
7
|
+
* The free-tier gate checks the major version, not a specific release, so a
|
|
8
|
+
* high compatibility version avoids pinning the extension to one OpenCode
|
|
9
|
+
* release.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_CLI_VERSION = "2.999.0";
|
|
12
|
+
|
|
13
|
+
/** Marks requests assembled for the extension's own provider. */
|
|
14
|
+
export const FINGERPRINT_MARKER_HEADER = "x-pi-opencode-free";
|
|
15
|
+
|
|
16
|
+
const CLI_VERSION_PATTERN = /^\d+\.\d+(?:\.\d+)?$/u;
|
|
17
|
+
|
|
18
|
+
/** Canonical OpenCode session headers mirrored by the v2 CLI. */
|
|
19
|
+
export const SESSION_HEADER_NAMES = [
|
|
20
|
+
"x-opencode-session",
|
|
21
|
+
"x-opencode-session-id",
|
|
22
|
+
"x-session-affinity",
|
|
23
|
+
"x-session-id",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
27
|
+
|
|
28
|
+
const SESSION_PREFIX = "ses_";
|
|
29
|
+
|
|
30
|
+
const SESSION_MASK_48 = 0xffffffffffffn;
|
|
31
|
+
|
|
32
|
+
const SESSION_TS_MASK_36 = 0xfffffffffn;
|
|
33
|
+
|
|
34
|
+
const SESSION_COUNTER = 1n;
|
|
35
|
+
|
|
36
|
+
const SESSION_HEX_LENGTH = 12;
|
|
37
|
+
|
|
38
|
+
const SESSION_SUFFIX_LENGTH = 14;
|
|
39
|
+
|
|
40
|
+
const SESSION_SEED_LENGTH = 20;
|
|
41
|
+
|
|
42
|
+
/** Largest multiple of the base62 alphabet size that fits in a byte. */
|
|
43
|
+
const BASE62_UNBIASED_LIMIT = BASE62.length * 4;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders the base62 suffix without modulo bias.
|
|
47
|
+
*
|
|
48
|
+
* Bytes at or above the largest full multiple of the alphabet are rejected so
|
|
49
|
+
* every digit keeps the same probability. Deterministic hash output supplies
|
|
50
|
+
* more bytes if the seed runs out after rejections.
|
|
51
|
+
*
|
|
52
|
+
* @param seed - At least twenty bytes; the suffix starts at byte six
|
|
53
|
+
* @returns Exactly fourteen base62 characters
|
|
54
|
+
*/
|
|
55
|
+
function createSessionSuffix(seed: Uint8Array): string {
|
|
56
|
+
const digits: string[] = [];
|
|
57
|
+
let bytes: Uint8Array = seed.subarray(6, SESSION_SEED_LENGTH);
|
|
58
|
+
let counter = 0;
|
|
59
|
+
|
|
60
|
+
while (digits.length < SESSION_SUFFIX_LENGTH) {
|
|
61
|
+
for (const byte of bytes) {
|
|
62
|
+
if (digits.length === SESSION_SUFFIX_LENGTH) break;
|
|
63
|
+
|
|
64
|
+
if (byte >= BASE62_UNBIASED_LIMIT) continue;
|
|
65
|
+
|
|
66
|
+
let index = byte;
|
|
67
|
+
|
|
68
|
+
while (index >= BASE62.length) index -= BASE62.length;
|
|
69
|
+
digits.push(BASE62[index] ?? "0");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (digits.length === SESSION_SUFFIX_LENGTH) break;
|
|
73
|
+
|
|
74
|
+
counter += 1;
|
|
75
|
+
bytes = createHash("sha256").update(seed).update(String(counter)).digest();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return digits.join("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolves the client version carried by the request fingerprint.
|
|
83
|
+
*
|
|
84
|
+
* @param environment - Environment variables visible to the extension
|
|
85
|
+
* @returns `OPENCODE_FREE_CLI_VERSION` when valid, else the compatibility version
|
|
86
|
+
*/
|
|
87
|
+
export function resolveCliVersion(environment: Record<string, string | undefined>): string {
|
|
88
|
+
const configured = environment.OPENCODE_FREE_CLI_VERSION?.trim();
|
|
89
|
+
|
|
90
|
+
if (configured && CLI_VERSION_PATTERN.test(configured)) return configured;
|
|
91
|
+
|
|
92
|
+
return DEFAULT_CLI_VERSION;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Builds the OpenCode v2 CLI user agent for a client version.
|
|
97
|
+
*
|
|
98
|
+
* @param cliVersion - Version placed between the `latest` channel and `cli` client
|
|
99
|
+
* @returns The user agent expected by the free-tier gate
|
|
100
|
+
*/
|
|
101
|
+
export function buildUserAgent(cliVersion: string): string {
|
|
102
|
+
return `opencode/latest/${cliVersion}/cli`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Tool declaration required by the free-tier gate. The schema matches the
|
|
107
|
+
* OpenCode v2 CLI's own `read` tool declaration.
|
|
108
|
+
*/
|
|
109
|
+
export const READ_TOOL = {
|
|
110
|
+
type: "function",
|
|
111
|
+
function: {
|
|
112
|
+
name: "read",
|
|
113
|
+
description:
|
|
114
|
+
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
|
115
|
+
parameters: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
path: { type: "string", description: "File or directory to read" },
|
|
119
|
+
offset: {
|
|
120
|
+
type: "integer",
|
|
121
|
+
minimum: 0,
|
|
122
|
+
description: "The line or directory entry to start reading from (1-based)",
|
|
123
|
+
},
|
|
124
|
+
limit: {
|
|
125
|
+
type: "integer",
|
|
126
|
+
minimum: 0,
|
|
127
|
+
description:
|
|
128
|
+
"The maximum number of lines or directory entries to read (defaults to and capped at 2000)",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ["path"],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
strict: false,
|
|
135
|
+
},
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Creates the random seed backing a canonical session id.
|
|
140
|
+
*
|
|
141
|
+
* @returns Twenty cryptographically random bytes for a canonical session identifier
|
|
142
|
+
* @throws If cryptographic random byte generation fails
|
|
143
|
+
*/
|
|
144
|
+
export function createSessionSeed(): Uint8Array {
|
|
145
|
+
return randomBytes(SESSION_SEED_LENGTH);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Renders OpenCode CLI's canonical session identifier shape.
|
|
150
|
+
*
|
|
151
|
+
* The 48-bit hex half reproduces `Identifier.descending()` with the counter
|
|
152
|
+
* pinned to one, so the low twelve bits always read `ffe`. The high 36 bits are
|
|
153
|
+
* derived from the seed, matching the reference implementation, while the
|
|
154
|
+
* fourteen-character suffix is derived from the seed.
|
|
155
|
+
*
|
|
156
|
+
* @param seed - At least twenty bytes; defaults to fresh cryptographic randomness
|
|
157
|
+
* @returns A `ses_` identifier accepted by the free-tier gate
|
|
158
|
+
* @throws If the seed contains fewer than twenty bytes or default seed generation fails
|
|
159
|
+
*/
|
|
160
|
+
export function createCanonicalSessionId(seed: Uint8Array = createSessionSeed()): string {
|
|
161
|
+
if (seed.length < SESSION_SEED_LENGTH) {
|
|
162
|
+
throw new Error(`Session seed must contain at least ${SESSION_SEED_LENGTH} bytes`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const timestampBits =
|
|
166
|
+
((BigInt(seed[0] ?? 0) << 28n) |
|
|
167
|
+
(BigInt(seed[1] ?? 0) << 20n) |
|
|
168
|
+
(BigInt(seed[2] ?? 0) << 12n) |
|
|
169
|
+
(BigInt(seed[3] ?? 0) << 4n) |
|
|
170
|
+
(BigInt(seed[4] ?? 0) >> 4n)) &
|
|
171
|
+
SESSION_TS_MASK_36;
|
|
172
|
+
|
|
173
|
+
const encoded = ~((timestampBits << 12n) | SESSION_COUNTER) & SESSION_MASK_48;
|
|
174
|
+
|
|
175
|
+
return `${SESSION_PREFIX}${encoded.toString(16).padStart(SESSION_HEX_LENGTH, "0")}${createSessionSuffix(seed)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Builds the static headers sent with every provider request.
|
|
180
|
+
*
|
|
181
|
+
* @param sessionId - Canonical session identifier for the active Pi session
|
|
182
|
+
* @param cliVersion - Client version carried by the user agent
|
|
183
|
+
* @returns Provider headers carrying the v2 CLI fingerprint
|
|
184
|
+
*/
|
|
185
|
+
export function buildFingerprintHeaders(sessionId: string, cliVersion: string) {
|
|
186
|
+
return {
|
|
187
|
+
"User-Agent": buildUserAgent(cliVersion),
|
|
188
|
+
"x-opencode-client": "cli",
|
|
189
|
+
"x-opencode-project": "global",
|
|
190
|
+
[FINGERPRINT_MARKER_HEADER]: "1",
|
|
191
|
+
"x-opencode-session": sessionId,
|
|
192
|
+
"x-opencode-session-id": sessionId,
|
|
193
|
+
"x-session-affinity": sessionId,
|
|
194
|
+
"x-session-id": sessionId,
|
|
195
|
+
} satisfies Record<string, string>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Updates the active session id on requests assembled for this provider.
|
|
200
|
+
*
|
|
201
|
+
* The marker header keeps the mutation from leaking into built-in providers
|
|
202
|
+
* that share the OpenCode endpoint but do not use the free-tier fingerprint.
|
|
203
|
+
* Matching header names are replaced case-insensitively, and the legacy
|
|
204
|
+
* `x-opencode-request` header is removed. Unmarked requests are left unchanged.
|
|
205
|
+
*
|
|
206
|
+
* @param headers - Mutable request headers supplied by Pi
|
|
207
|
+
* @param sessionId - Canonical session identifier for the active Pi session
|
|
208
|
+
* @param cliVersion - Client version carried by the user agent
|
|
209
|
+
* @returns Whether the request belonged to this extension
|
|
210
|
+
*/
|
|
211
|
+
export function applyFingerprintHeaders(
|
|
212
|
+
headers: ProviderHeaders,
|
|
213
|
+
sessionId: string,
|
|
214
|
+
cliVersion: string,
|
|
215
|
+
): boolean {
|
|
216
|
+
if (!hasFingerprintMarker(headers)) return false;
|
|
217
|
+
|
|
218
|
+
setHeader(headers, "User-Agent", buildUserAgent(cliVersion));
|
|
219
|
+
setHeader(headers, "x-opencode-client", "cli");
|
|
220
|
+
setHeader(headers, "x-opencode-project", "global");
|
|
221
|
+
|
|
222
|
+
for (const name of SESSION_HEADER_NAMES) {
|
|
223
|
+
setHeader(headers, name, sessionId);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const name of Object.keys(headers)) {
|
|
227
|
+
if (name.toLowerCase() === "x-opencode-request") delete headers[name];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Checks for a case-insensitive marker header whose value is exactly `"1"`. */
|
|
234
|
+
function hasFingerprintMarker(headers: ProviderHeaders): boolean {
|
|
235
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
236
|
+
if (name.toLowerCase() === FINGERPRINT_MARKER_HEADER && value === "1") return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Replaces all case variants of a header in place with the supplied spelling and value. */
|
|
243
|
+
function setHeader(headers: ProviderHeaders, name: string, value: string): void {
|
|
244
|
+
for (const existing of Object.keys(headers)) {
|
|
245
|
+
if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
headers[name] = value;
|
|
249
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
applyFingerprintHeaders,
|
|
6
|
+
buildFingerprintHeaders,
|
|
7
|
+
createCanonicalSessionId,
|
|
8
|
+
createSessionSeed,
|
|
9
|
+
resolveCliVersion,
|
|
10
|
+
} from "./fingerprint.ts";
|
|
11
|
+
import {
|
|
12
|
+
configsFromStoredModels,
|
|
13
|
+
discoverFreeModels,
|
|
14
|
+
initialFreeModels,
|
|
15
|
+
resolveApiKey,
|
|
16
|
+
resolveBaseUrl,
|
|
17
|
+
storedModelsFromConfigs,
|
|
18
|
+
stripTrailingSlashes,
|
|
19
|
+
type FreeChatModelConfig,
|
|
20
|
+
} from "./models.ts";
|
|
21
|
+
import { isProviderRequestPayload, normalizeFreeRequestPayload } from "./request.ts";
|
|
22
|
+
|
|
23
|
+
/** Provider id used by `/model` and provider-scoped request handlers. */
|
|
24
|
+
export const PROVIDER_ID = "opencode-free";
|
|
25
|
+
|
|
26
|
+
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
27
|
+
|
|
28
|
+
/** Injectable runtime dependencies used by tests and the default extension export. */
|
|
29
|
+
export interface OpencodeFreeDependencies {
|
|
30
|
+
randomSeed: () => Uint8Array;
|
|
31
|
+
fetcher: typeof fetch;
|
|
32
|
+
environment: Record<string, string | undefined>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Registers the OpenCode free provider and its request hooks.
|
|
37
|
+
*
|
|
38
|
+
* Seeds the catalog from bundled models, rotates session identity on session
|
|
39
|
+
* start, and normalizes requests for this provider. Discovery runs when Pi
|
|
40
|
+
* invokes the registered model refresh hook.
|
|
41
|
+
*
|
|
42
|
+
* @param pi - Extension API supplied by Pi
|
|
43
|
+
* @param dependencies - Runtime dependencies for discovery and session generation
|
|
44
|
+
* @throws If seed generation fails or returns fewer than twenty bytes; Pi API errors propagate
|
|
45
|
+
*/
|
|
46
|
+
export function registerOpencodeFree(
|
|
47
|
+
pi: ExtensionAPI,
|
|
48
|
+
dependencies: OpencodeFreeDependencies,
|
|
49
|
+
): void {
|
|
50
|
+
let sessionId = createCanonicalSessionId(dependencies.randomSeed());
|
|
51
|
+
|
|
52
|
+
const baseUrl = resolveBaseUrl(dependencies.environment);
|
|
53
|
+
const apiKey = resolveApiKey(dependencies.environment);
|
|
54
|
+
const cliVersion = resolveCliVersion(dependencies.environment);
|
|
55
|
+
|
|
56
|
+
pi.on("session_start", () => {
|
|
57
|
+
sessionId = createCanonicalSessionId(dependencies.randomSeed());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.on("before_provider_headers", (event) => {
|
|
61
|
+
applyFingerprintHeaders(event.headers, sessionId, cliVersion);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
65
|
+
if (ctx.model?.provider !== PROVIDER_ID) return undefined;
|
|
66
|
+
|
|
67
|
+
if (!isProviderRequestPayload(event.payload)) return undefined;
|
|
68
|
+
|
|
69
|
+
return normalizeFreeRequestPayload(event.payload);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const config: ProviderConfig = {
|
|
73
|
+
name: "OpenCode Free",
|
|
74
|
+
baseUrl,
|
|
75
|
+
apiKey,
|
|
76
|
+
api: "openai-completions",
|
|
77
|
+
authHeader: true,
|
|
78
|
+
headers: buildFingerprintHeaders(sessionId, cliVersion),
|
|
79
|
+
models: initialFreeModels(),
|
|
80
|
+
refreshModels: async (context) =>
|
|
81
|
+
refreshModels(context, dependencies, baseUrl, sessionId, cliVersion),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
pi.registerProvider(PROVIDER_ID, config);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Refreshes the catalog, falling back to stored models or bundled models when
|
|
89
|
+
* offline, canceled, or discovery returns no usable models. Discovery has an
|
|
90
|
+
* eight-second abort deadline. Successful results are published for persistence;
|
|
91
|
+
* publication failures are ignored and the discovered models are still returned.
|
|
92
|
+
*
|
|
93
|
+
* @param fallbackBaseUrl - Endpoint used when the refresh context has no override
|
|
94
|
+
* @throws Propagates model metadata conversion errors from discovery
|
|
95
|
+
*/
|
|
96
|
+
async function refreshModels(
|
|
97
|
+
context: RefreshModelsContext,
|
|
98
|
+
dependencies: OpencodeFreeDependencies,
|
|
99
|
+
fallbackBaseUrl: string,
|
|
100
|
+
sessionId: string,
|
|
101
|
+
cliVersion: string,
|
|
102
|
+
): Promise<FreeChatModelConfig[]> {
|
|
103
|
+
const baseUrl = stripTrailingSlashes(context.baseUrl ?? fallbackBaseUrl);
|
|
104
|
+
const cachedModels = configsFromStoredModels(context.stored?.models ?? [], PROVIDER_ID);
|
|
105
|
+
const fallbackModels = cachedModels.length > 0 ? cachedModels : initialFreeModels();
|
|
106
|
+
|
|
107
|
+
if (!context.allowNetwork || context.signal.aborted) return fallbackModels;
|
|
108
|
+
|
|
109
|
+
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(DISCOVERY_TIMEOUT_MS)]);
|
|
110
|
+
|
|
111
|
+
const models = await discoverFreeModels({
|
|
112
|
+
baseUrl,
|
|
113
|
+
headers: buildFingerprintHeaders(sessionId, cliVersion),
|
|
114
|
+
signal,
|
|
115
|
+
fetcher: dependencies.fetcher,
|
|
116
|
+
allowNetwork: context.allowNetwork,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!models || context.signal.aborted) return fallbackModels;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await context.publish({
|
|
123
|
+
persist: {
|
|
124
|
+
models: storedModelsFromConfigs(models, PROVIDER_ID, baseUrl),
|
|
125
|
+
checkedAt: Date.now(),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// Keep a valid in-memory catalog even when persistent storage is unavailable.
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return models;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const defaultDependencies: OpencodeFreeDependencies = {
|
|
136
|
+
randomSeed: createSessionSeed,
|
|
137
|
+
fetcher: globalThis.fetch,
|
|
138
|
+
environment: process.env,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pi extension entry point. Registers the `opencode-free` provider.
|
|
143
|
+
*
|
|
144
|
+
* @param pi - Extension API supplied by Pi
|
|
145
|
+
*/
|
|
146
|
+
export default function piOpencodeFree(pi: ExtensionAPI): void {
|
|
147
|
+
registerOpencodeFree(pi, defaultDependencies);
|
|
148
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { isModelType, type AnyModel, type Api, type Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
getBuiltinModelDataGeneratedAt,
|
|
4
|
+
getBuiltinModels,
|
|
5
|
+
} from "@earendil-works/pi-ai/providers/all";
|
|
6
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/** Default OpenAI-compatible OpenCode inference endpoint. */
|
|
9
|
+
export const DEFAULT_BASE_URL = "https://opencode.ai/inference/v1";
|
|
10
|
+
|
|
11
|
+
/** Pi's native OpenCode catalog overlay, as served by pi.dev. */
|
|
12
|
+
export const MODEL_CATALOG_URL = "https://pi.dev/api/models/providers/opencode?types=chat";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PUBLIC_API_KEY = "public";
|
|
15
|
+
|
|
16
|
+
/** Chat model configuration accepted by `ProviderConfig.models`. */
|
|
17
|
+
export type FreeChatModelConfig = Extract<ProviderModelConfig, { reasoning: boolean }>;
|
|
18
|
+
|
|
19
|
+
interface RemoteCost {
|
|
20
|
+
input?: number;
|
|
21
|
+
output?: number;
|
|
22
|
+
cacheRead?: number;
|
|
23
|
+
cacheWrite?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RemoteCatalogEntry {
|
|
27
|
+
id: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
api?: string;
|
|
31
|
+
input?: readonly string[];
|
|
32
|
+
cost?: RemoteCost;
|
|
33
|
+
contextWindow?: number;
|
|
34
|
+
maxTokens?: number;
|
|
35
|
+
reasoning?: boolean;
|
|
36
|
+
thinkingLevelMap?: FreeChatModelConfig["thinkingLevelMap"];
|
|
37
|
+
compat?: FreeChatModelConfig["compat"];
|
|
38
|
+
inputLimits?: FreeChatModelConfig["inputLimits"];
|
|
39
|
+
promptCache?: FreeChatModelConfig["promptCache"];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface LiveModel {
|
|
43
|
+
id?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface LiveModelsResponse {
|
|
47
|
+
data?: readonly LiveModel[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Inputs for live free-model discovery. */
|
|
51
|
+
export interface DiscoverFreeModelsOptions {
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
headers: Record<string, string>;
|
|
54
|
+
signal: AbortSignal;
|
|
55
|
+
fetcher: typeof fetch;
|
|
56
|
+
allowNetwork: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Removes trailing slash characters without a backtracking regular expression.
|
|
61
|
+
*
|
|
62
|
+
* @param value - URL text to normalize
|
|
63
|
+
* @returns The value without trailing slashes
|
|
64
|
+
*/
|
|
65
|
+
export function stripTrailingSlashes(value: string): string {
|
|
66
|
+
let end = value.length;
|
|
67
|
+
|
|
68
|
+
while (end > 0 && value.charCodeAt(end - 1) === 0x2f) end -= 1;
|
|
69
|
+
|
|
70
|
+
return value.slice(0, end);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves the provider base URL, allowing a local proxy or alternate gateway.
|
|
75
|
+
*
|
|
76
|
+
* @param environment - Environment variables visible to the extension
|
|
77
|
+
* @returns The configured URL without trailing slashes, or the OpenCode default
|
|
78
|
+
*/
|
|
79
|
+
export function resolveBaseUrl(environment: Record<string, string | undefined>): string {
|
|
80
|
+
const configured = environment.OPENCODE_FREE_BASE_URL?.trim();
|
|
81
|
+
|
|
82
|
+
if (!configured) return DEFAULT_BASE_URL;
|
|
83
|
+
|
|
84
|
+
return stripTrailingSlashes(configured);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the bearer token used for inference requests.
|
|
89
|
+
*
|
|
90
|
+
* @param environment - Environment variables visible to the extension
|
|
91
|
+
* @returns `OPENCODE_FREE_API_KEY`, then `OPENCODE_API_KEY`, then `public`
|
|
92
|
+
*/
|
|
93
|
+
export function resolveApiKey(environment: Record<string, string | undefined>): string {
|
|
94
|
+
const freeKey = environment.OPENCODE_FREE_API_KEY?.trim();
|
|
95
|
+
|
|
96
|
+
if (freeKey) return freeKey;
|
|
97
|
+
|
|
98
|
+
const openCodeKey = environment.OPENCODE_API_KEY?.trim();
|
|
99
|
+
|
|
100
|
+
return openCodeKey || DEFAULT_PUBLIC_API_KEY;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns the free chat models in Pi's built-in OpenCode catalog.
|
|
105
|
+
*
|
|
106
|
+
* This seeds the provider before the first network refresh. The live catalog
|
|
107
|
+
* replaces it as soon as discovery succeeds.
|
|
108
|
+
*
|
|
109
|
+
* @returns Free chat model configurations from Pi's bundled catalog
|
|
110
|
+
*/
|
|
111
|
+
export function initialFreeModels(): FreeChatModelConfig[] {
|
|
112
|
+
return [...freeBuiltinModels().values()];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Restores this provider's cached OpenAI-completions chat models from Pi's persisted store.
|
|
117
|
+
* Model costs are preserved without checking whether they are zero.
|
|
118
|
+
*
|
|
119
|
+
* @param models - Models from `RefreshModelsContext.stored`
|
|
120
|
+
* @param providerId - Provider id that owns the cached catalog
|
|
121
|
+
* @returns Chat model configurations owned by this provider
|
|
122
|
+
*/
|
|
123
|
+
export function configsFromStoredModels(
|
|
124
|
+
models: readonly AnyModel[],
|
|
125
|
+
providerId: string,
|
|
126
|
+
): FreeChatModelConfig[] {
|
|
127
|
+
const configs: FreeChatModelConfig[] = [];
|
|
128
|
+
|
|
129
|
+
for (const model of models) {
|
|
130
|
+
if (!isModelType(model, "chat")) continue;
|
|
131
|
+
|
|
132
|
+
if (model.provider !== providerId) continue;
|
|
133
|
+
|
|
134
|
+
if (model.api !== "openai-completions") continue;
|
|
135
|
+
|
|
136
|
+
configs.push(fromModel(model));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return configs;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Converts provider configurations into the catalog shape Pi persists.
|
|
144
|
+
*
|
|
145
|
+
* @param models - Discovered free chat models
|
|
146
|
+
* @param providerId - Provider id that owns the catalog
|
|
147
|
+
* @param baseUrl - Current provider base URL
|
|
148
|
+
* @returns Full chat models suitable for `ModelsStoreEntry.models`
|
|
149
|
+
*/
|
|
150
|
+
export function storedModelsFromConfigs(
|
|
151
|
+
models: readonly FreeChatModelConfig[],
|
|
152
|
+
providerId: string,
|
|
153
|
+
baseUrl: string,
|
|
154
|
+
): Model<"openai-completions">[] {
|
|
155
|
+
return models.map((config) => {
|
|
156
|
+
const model: Model<"openai-completions"> = {
|
|
157
|
+
id: config.id,
|
|
158
|
+
name: config.name,
|
|
159
|
+
provider: providerId,
|
|
160
|
+
api: "openai-completions",
|
|
161
|
+
baseUrl,
|
|
162
|
+
input: config.input,
|
|
163
|
+
cost: config.cost,
|
|
164
|
+
contextWindow: config.contextWindow,
|
|
165
|
+
maxTokens: config.maxTokens,
|
|
166
|
+
reasoning: config.reasoning,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (config.thinkingLevelMap) model.thinkingLevelMap = config.thinkingLevelMap;
|
|
170
|
+
|
|
171
|
+
if (config.compat) model.compat = config.compat;
|
|
172
|
+
|
|
173
|
+
if (config.inputLimits) model.inputLimits = config.inputLimits;
|
|
174
|
+
|
|
175
|
+
if (config.promptCache) model.promptCache = config.promptCache;
|
|
176
|
+
|
|
177
|
+
return model;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Discovers free chat models from the live endpoint and Pi's native catalog.
|
|
183
|
+
*
|
|
184
|
+
* The live endpoint decides which models currently exist. Pi's OpenCode
|
|
185
|
+
* catalog supplies their capabilities and compatibility flags, so this
|
|
186
|
+
* extension does not maintain a second metadata translation.
|
|
187
|
+
* A newer remote catalog overlays bundled metadata; otherwise bundled metadata
|
|
188
|
+
* is used. Both endpoints must respond successfully. Fetch, HTTP, JSON parsing,
|
|
189
|
+
* and response-shape failures return `undefined`, as do disabled networking,
|
|
190
|
+
* an already-aborted signal, and an empty intersection.
|
|
191
|
+
*
|
|
192
|
+
* @param options - Endpoint, headers, abort signal, and fetcher
|
|
193
|
+
* @returns Free chat model configurations, or `undefined` when discovery cannot verify them
|
|
194
|
+
* @throws If model metadata conversion fails, such as for a non-string catalog name
|
|
195
|
+
*/
|
|
196
|
+
export async function discoverFreeModels(
|
|
197
|
+
options: DiscoverFreeModelsOptions,
|
|
198
|
+
): Promise<FreeChatModelConfig[] | undefined> {
|
|
199
|
+
if (!options.allowNetwork || options.signal.aborted) return undefined;
|
|
200
|
+
|
|
201
|
+
const [liveIds, catalog] = await Promise.all([
|
|
202
|
+
fetchLiveModelIds(options),
|
|
203
|
+
fetchModelCatalog(options),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
if (!liveIds || liveIds.length === 0 || !catalog) return undefined;
|
|
207
|
+
|
|
208
|
+
const models = selectFreeModels(liveIds, freeCatalogModels(catalog));
|
|
209
|
+
|
|
210
|
+
return models.length > 0 ? models : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Indexes bundled OpenAI-completions models with zero input and output costs by model id. */
|
|
214
|
+
function freeBuiltinModels(): Map<string, FreeChatModelConfig> {
|
|
215
|
+
const models = new Map<string, FreeChatModelConfig>();
|
|
216
|
+
|
|
217
|
+
for (const model of getBuiltinModels("opencode")) {
|
|
218
|
+
if (model.api !== "openai-completions") continue;
|
|
219
|
+
|
|
220
|
+
if (model.cost.input !== 0 || model.cost.output !== 0) continue;
|
|
221
|
+
|
|
222
|
+
models.set(model.id, fromModel(model));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return models;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Overlays qualifying remote entries on bundled models; rejected or absent entries leave them intact. */
|
|
229
|
+
function freeCatalogModels(
|
|
230
|
+
catalog: readonly RemoteCatalogEntry[],
|
|
231
|
+
): Map<string, FreeChatModelConfig> {
|
|
232
|
+
const models = freeBuiltinModels();
|
|
233
|
+
|
|
234
|
+
for (const entry of catalog) {
|
|
235
|
+
const config = configFromRemote(entry);
|
|
236
|
+
|
|
237
|
+
if (config) models.set(config.id, config);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return models;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Copies model capabilities into a provider configuration, omitting provider and endpoint identity. */
|
|
244
|
+
function fromModel(model: Model<Api>): FreeChatModelConfig {
|
|
245
|
+
const config: FreeChatModelConfig = {
|
|
246
|
+
id: model.id,
|
|
247
|
+
name: model.name,
|
|
248
|
+
input: model.input,
|
|
249
|
+
cost: model.cost,
|
|
250
|
+
contextWindow: model.contextWindow,
|
|
251
|
+
maxTokens: model.maxTokens,
|
|
252
|
+
reasoning: model.reasoning,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (model.thinkingLevelMap) config.thinkingLevelMap = model.thinkingLevelMap;
|
|
256
|
+
|
|
257
|
+
if (model.compat) config.compat = model.compat;
|
|
258
|
+
|
|
259
|
+
if (model.inputLimits) config.inputLimits = model.inputLimits;
|
|
260
|
+
|
|
261
|
+
if (model.promptCache) config.promptCache = model.promptCache;
|
|
262
|
+
|
|
263
|
+
return config;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Converts a zero-input/output-cost OpenAI-completions chat entry with positive
|
|
268
|
+
* finite limits, reasoning, and input metadata, or returns `undefined` when those checks fail.
|
|
269
|
+
* Positive fractional token limits are rounded down; cache costs are preserved.
|
|
270
|
+
*
|
|
271
|
+
* @throws If an otherwise qualifying entry has a non-null name that is not a string
|
|
272
|
+
*/
|
|
273
|
+
function configFromRemote(entry: RemoteCatalogEntry): FreeChatModelConfig | undefined {
|
|
274
|
+
if (entry.type !== undefined && entry.type !== "chat") return undefined;
|
|
275
|
+
|
|
276
|
+
if (entry.api !== "openai-completions") return undefined;
|
|
277
|
+
|
|
278
|
+
if (entry.cost?.input !== 0 || entry.cost.output !== 0) return undefined;
|
|
279
|
+
|
|
280
|
+
if (!isPositiveInteger(entry.contextWindow)) return undefined;
|
|
281
|
+
|
|
282
|
+
if (!isPositiveInteger(entry.maxTokens)) return undefined;
|
|
283
|
+
|
|
284
|
+
if (entry.reasoning !== true && entry.reasoning !== false) return undefined;
|
|
285
|
+
|
|
286
|
+
if (!entry.input || !isStringArray(entry.input)) return undefined;
|
|
287
|
+
|
|
288
|
+
const config: FreeChatModelConfig = {
|
|
289
|
+
id: entry.id,
|
|
290
|
+
name: entry.name?.trim() || entry.id,
|
|
291
|
+
input: entry.input.includes("image") ? ["text", "image"] : ["text"],
|
|
292
|
+
cost: {
|
|
293
|
+
input: 0,
|
|
294
|
+
output: 0,
|
|
295
|
+
cacheRead: entry.cost.cacheRead ?? 0,
|
|
296
|
+
cacheWrite: entry.cost.cacheWrite ?? 0,
|
|
297
|
+
},
|
|
298
|
+
contextWindow: Math.floor(entry.contextWindow),
|
|
299
|
+
maxTokens: Math.floor(entry.maxTokens),
|
|
300
|
+
reasoning: entry.reasoning,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (entry.thinkingLevelMap) config.thinkingLevelMap = entry.thinkingLevelMap;
|
|
304
|
+
|
|
305
|
+
if (entry.compat) config.compat = entry.compat;
|
|
306
|
+
|
|
307
|
+
if (entry.inputLimits) config.inputLimits = entry.inputLimits;
|
|
308
|
+
|
|
309
|
+
if (entry.promptCache) config.promptCache = entry.promptCache;
|
|
310
|
+
|
|
311
|
+
return config;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Selects live ids with known free metadata, preserving live order and duplicate ids. */
|
|
315
|
+
function selectFreeModels(
|
|
316
|
+
liveIds: readonly string[],
|
|
317
|
+
metadata: ReadonlyMap<string, FreeChatModelConfig>,
|
|
318
|
+
): FreeChatModelConfig[] {
|
|
319
|
+
const models: FreeChatModelConfig[] = [];
|
|
320
|
+
|
|
321
|
+
for (const id of liveIds) {
|
|
322
|
+
const config = metadata.get(id);
|
|
323
|
+
|
|
324
|
+
if (!config) continue;
|
|
325
|
+
|
|
326
|
+
models.push(config);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return models;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Fetches `/models` with the supplied headers and signal, omitting empty ids.
|
|
334
|
+
* Returns `undefined` on HTTP, fetch, JSON parsing, or response-shape failure,
|
|
335
|
+
* including an aborted fetch; a valid empty list returns an empty array.
|
|
336
|
+
*/
|
|
337
|
+
async function fetchLiveModelIds(
|
|
338
|
+
options: DiscoverFreeModelsOptions,
|
|
339
|
+
): Promise<readonly string[] | undefined> {
|
|
340
|
+
try {
|
|
341
|
+
const response = await options.fetcher(`${options.baseUrl}/models`, {
|
|
342
|
+
headers: { ...options.headers, Accept: "application/json" },
|
|
343
|
+
signal: options.signal,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (!response.ok) return undefined;
|
|
347
|
+
|
|
348
|
+
const body: unknown = await response.json();
|
|
349
|
+
|
|
350
|
+
if (!isLiveModelsResponse(body)) return undefined;
|
|
351
|
+
|
|
352
|
+
const ids: string[] = [];
|
|
353
|
+
|
|
354
|
+
for (const model of body.data ?? []) {
|
|
355
|
+
if (model.id) ids.push(model.id);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return ids;
|
|
359
|
+
} catch {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Fetches Pi's OpenCode catalog with the supplied signal and without provider headers.
|
|
366
|
+
* Returns an empty array when the bundled catalog is at least as recent, or
|
|
367
|
+
* `undefined` on HTTP, fetch, JSON parsing, or response-shape failure, including abort.
|
|
368
|
+
*/
|
|
369
|
+
async function fetchModelCatalog(
|
|
370
|
+
options: DiscoverFreeModelsOptions,
|
|
371
|
+
): Promise<readonly RemoteCatalogEntry[] | undefined> {
|
|
372
|
+
try {
|
|
373
|
+
const response = await options.fetcher(MODEL_CATALOG_URL, {
|
|
374
|
+
headers: { Accept: "application/json" },
|
|
375
|
+
signal: options.signal,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (!response.ok) return undefined;
|
|
379
|
+
|
|
380
|
+
const body: unknown = await response.json();
|
|
381
|
+
|
|
382
|
+
if (!isRemoteCatalog(body)) return undefined;
|
|
383
|
+
|
|
384
|
+
return isCatalogNewer(response.headers.get("last-modified")) ? body : [];
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Compares the remote Last-Modified date with the bundled catalog timestamp.
|
|
392
|
+
* A missing or invalid remote date qualifies only when no bundled timestamp exists.
|
|
393
|
+
*/
|
|
394
|
+
function isCatalogNewer(lastModified: string | null): boolean {
|
|
395
|
+
const remoteGeneratedAt = Date.parse(lastModified ?? "");
|
|
396
|
+
const localGeneratedAt = getBuiltinModelDataGeneratedAt();
|
|
397
|
+
|
|
398
|
+
if (Number.isNaN(remoteGeneratedAt)) return localGeneratedAt === undefined;
|
|
399
|
+
|
|
400
|
+
return localGeneratedAt === undefined || remoteGeneratedAt > localGeneratedAt;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Requires a `data` array whose entries all have string ids; an empty array is valid. */
|
|
404
|
+
function isLiveModelsResponse(value: unknown): value is LiveModelsResponse {
|
|
405
|
+
if (value === null || typeof value !== "object" || !("data" in value)) return false;
|
|
406
|
+
|
|
407
|
+
const data = value.data;
|
|
408
|
+
|
|
409
|
+
if (!Array.isArray(data)) return false;
|
|
410
|
+
|
|
411
|
+
for (const model of data) {
|
|
412
|
+
if (!isLiveModel(model)) return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Checks only for a string id, allowing empty ids and leaving other fields unchecked. */
|
|
419
|
+
function isLiveModel(value: unknown): value is LiveModel {
|
|
420
|
+
if (value === null || typeof value !== "object" || !("id" in value)) return false;
|
|
421
|
+
|
|
422
|
+
return typeof value.id === "string";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Accepts an empty catalog or entries with string ids; capability fields are not validated here. */
|
|
426
|
+
function isRemoteCatalog(value: unknown): value is readonly RemoteCatalogEntry[] {
|
|
427
|
+
if (!Array.isArray(value)) return false;
|
|
428
|
+
|
|
429
|
+
for (const model of value) {
|
|
430
|
+
if (!isRemoteCatalogEntry(model)) return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Checks only for a string id, leaving optional catalog metadata unchecked. */
|
|
437
|
+
function isRemoteCatalogEntry(value: unknown): value is RemoteCatalogEntry {
|
|
438
|
+
return (
|
|
439
|
+
value !== null && typeof value === "object" && "id" in value && typeof value.id === "string"
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Accepts arrays containing only strings, including empty arrays. */
|
|
444
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
445
|
+
if (!Array.isArray(value)) return false;
|
|
446
|
+
|
|
447
|
+
for (const item of value) {
|
|
448
|
+
if (typeof item !== "string") return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Excludes NaN and infinities when validating numeric model limits. */
|
|
455
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
456
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Accepts finite values greater than zero, including fractions that callers round down. */
|
|
460
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
461
|
+
return isFiniteNumber(value) && value > 0;
|
|
462
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { READ_TOOL } from "./fingerprint.ts";
|
|
2
|
+
|
|
3
|
+
interface ToolFunctionReference {
|
|
4
|
+
name?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ToolDeclaration {
|
|
9
|
+
type?: string;
|
|
10
|
+
function?: ToolFunctionReference;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ToolChoiceObject {
|
|
14
|
+
type?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The subset of an OpenAI chat-completions payload the extension needs to inspect. */
|
|
18
|
+
export interface ProviderRequestPayload {
|
|
19
|
+
model?: string;
|
|
20
|
+
stream?: boolean;
|
|
21
|
+
tools?: readonly ToolDeclaration[];
|
|
22
|
+
tool_choice?: string | ToolChoiceObject;
|
|
23
|
+
temperature?: number;
|
|
24
|
+
max_tokens?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validates the subset of a provider request payload used for normalization.
|
|
29
|
+
*
|
|
30
|
+
* Other payload fields are intentionally ignored and survive reconstruction.
|
|
31
|
+
*
|
|
32
|
+
* @param value - Candidate provider request payload
|
|
33
|
+
* @returns Whether the payload has the shape needed for free-tier normalization
|
|
34
|
+
*/
|
|
35
|
+
export function isProviderRequestPayload(value: unknown): value is ProviderRequestPayload {
|
|
36
|
+
if (value === null || typeof value !== "object" || !("model" in value)) return false;
|
|
37
|
+
|
|
38
|
+
if (typeof value.model !== "string") return false;
|
|
39
|
+
|
|
40
|
+
if ("stream" in value && value.stream !== undefined && typeof value.stream !== "boolean") {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ("tools" in value && value.tools !== undefined) {
|
|
45
|
+
if (!Array.isArray(value.tools)) return false;
|
|
46
|
+
|
|
47
|
+
for (const tool of value.tools) {
|
|
48
|
+
if (!isToolDeclaration(tool)) return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if ("tool_choice" in value && value.tool_choice !== undefined) {
|
|
53
|
+
if (!isToolChoice(value.tool_choice)) return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Applies the free-tier request contract to a provider payload.
|
|
61
|
+
*
|
|
62
|
+
* The request must stream and must declare a tool named exactly `read`.
|
|
63
|
+
* Both `tool_choice: "none"` and `tool_choice: { type: "none" }` are removed
|
|
64
|
+
* because the gate rejects them, while every other field and tool declaration
|
|
65
|
+
* is preserved. The original payload is not mutated.
|
|
66
|
+
*
|
|
67
|
+
* @param payload - Validated provider request payload
|
|
68
|
+
* @returns A normalized copy suitable for the free-tier endpoint
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeFreeRequestPayload(
|
|
71
|
+
payload: ProviderRequestPayload,
|
|
72
|
+
): ProviderRequestPayload {
|
|
73
|
+
const tools = appendReadTool(payload.tools);
|
|
74
|
+
|
|
75
|
+
const normalized: ProviderRequestPayload = {
|
|
76
|
+
...payload,
|
|
77
|
+
stream: true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (tools !== payload.tools) normalized.tools = tools;
|
|
81
|
+
|
|
82
|
+
if (isNoneToolChoice(payload.tool_choice)) delete normalized.tool_choice;
|
|
83
|
+
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reuses tools containing an exact `read` name, or returns a new list with the default appended. */
|
|
88
|
+
function appendReadTool(tools: readonly ToolDeclaration[] | undefined): readonly ToolDeclaration[] {
|
|
89
|
+
if (!tools) return [READ_TOOL];
|
|
90
|
+
|
|
91
|
+
for (const tool of tools) {
|
|
92
|
+
if (tool.function?.name === "read") return tools;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [...tools, READ_TOOL];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Recognizes both the string and object forms of the rejected `none` tool choice. */
|
|
99
|
+
function isNoneToolChoice(value: unknown): value is "none" | ToolChoiceObject {
|
|
100
|
+
if (value === "none") return true;
|
|
101
|
+
|
|
102
|
+
return value !== null && typeof value === "object" && "type" in value && value.type === "none";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Accepts any string or non-null object without validating a particular tool-choice schema. */
|
|
106
|
+
function isToolChoice(value: unknown): value is string | ToolChoiceObject {
|
|
107
|
+
if (typeof value === "string") return true;
|
|
108
|
+
|
|
109
|
+
return value !== null && typeof value === "object";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Requires a function object with a string name; other declaration fields are not validated. */
|
|
113
|
+
function isToolDeclaration(value: unknown): value is ToolDeclaration {
|
|
114
|
+
if (value === null || typeof value !== "object" || !("function" in value)) return false;
|
|
115
|
+
|
|
116
|
+
const fn = value.function;
|
|
117
|
+
|
|
118
|
+
if (fn === null || typeof fn !== "object" || !("name" in fn)) return false;
|
|
119
|
+
|
|
120
|
+
return typeof fn.name === "string";
|
|
121
|
+
}
|