opencode-apigee 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +108 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +71 -0
- package/dist/settings.d.ts +53 -0
- package/dist/settings.js +103 -0
- package/dist/token.d.ts +18 -0
- package/dist/token.js +54 -0
- package/package.json +34 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chelayel
|
|
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,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-apigee
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use **Gemini on Vertex AI behind an Apigee gateway** as a model provider in
|
|
4
|
+
[OpenCode](https://opencode.ai).
|
|
5
|
+
|
|
6
|
+
Many companies expose Vertex AI through an Apigee gateway that wants an OAuth
|
|
7
|
+
token from its own client-credentials endpoint instead of a Google API key or
|
|
8
|
+
gcloud login. OpenCode's Google providers can't do that. This plugin does: it
|
|
9
|
+
gets the token with your client id and secret, keeps it fresh, and sends every
|
|
10
|
+
request to the gateway with it. Everything else is OpenCode's standard Gemini
|
|
11
|
+
support.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
**1. Add the plugin** to `~/.config/opencode/opencode.json` (or a project's
|
|
16
|
+
`opencode.json`), with your gateway's details:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"$schema": "https://opencode.ai/config.json",
|
|
21
|
+
"plugin": [
|
|
22
|
+
["opencode-apigee", {
|
|
23
|
+
"gateway": "my-gw.example.com",
|
|
24
|
+
"project": "my-gcp-project",
|
|
25
|
+
"location": "us-central1",
|
|
26
|
+
"tokenUrl": "https://auth.example.com/oauth/token",
|
|
27
|
+
"clientId": "my-client-id",
|
|
28
|
+
"models": ["gemini-3.8-flash", "gemini-3.1-pro"]
|
|
29
|
+
}]
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**2. Store the client secret** in OpenCode's credential store:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
opencode auth login
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Pick **Gemini via Apigee** from the list (type "apigee" to filter) and paste
|
|
41
|
+
the client secret where it asks for an API key.
|
|
42
|
+
|
|
43
|
+
(or set `APIGEE_CLIENT_SECRET` in the environment).
|
|
44
|
+
|
|
45
|
+
**3. Pick a model**: `/models` in OpenCode → **Gemini via Apigee**, or
|
|
46
|
+
`opencode run --model apigee/gemini-3.8-flash "…"`.
|
|
47
|
+
|
|
48
|
+
### Already using AI Relay?
|
|
49
|
+
|
|
50
|
+
If you set the gateway up with [AI Relay](https://github.com/Chelayel/ai-relay)
|
|
51
|
+
(`airelay gemini setup`), the plugin reads the same values from
|
|
52
|
+
`~/.airelay/config.properties` — gateway, project, location, token URL, client
|
|
53
|
+
id and secret, and the published models. Then step 1 is just:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{ "plugin": ["opencode-apigee"] }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
Each setting comes from, in order: the secret stored with `opencode auth login`
|
|
62
|
+
(secret only), the plugin options, an environment variable, then AI Relay's
|
|
63
|
+
config.
|
|
64
|
+
|
|
65
|
+
| Option | Environment | AI Relay key | |
|
|
66
|
+
| --- | --- | --- | --- |
|
|
67
|
+
| `gateway` | `APIGEE_GATEWAY` | `vertex.endpoint` | Gateway host (`https://` is assumed) or a full URL |
|
|
68
|
+
| `project` | `APIGEE_PROJECT` | `vertex.project` | Google Cloud project behind the gateway |
|
|
69
|
+
| `location` | `APIGEE_LOCATION` | `vertex.location` | Default `us-central1` |
|
|
70
|
+
| `tokenUrl` | `APIGEE_TOKEN_URL` | `apigee.token.url` | OAuth client-credentials endpoint |
|
|
71
|
+
| `clientId` | `APIGEE_CLIENT_ID` | `apigee.client.id` | |
|
|
72
|
+
| `clientSecret` | `APIGEE_CLIENT_SECRET` | `apigee.client.secret` | Prefer `opencode auth login` over the config file |
|
|
73
|
+
| `models` | `APIGEE_MODELS` | `apigee.agents` | Model ids the gateway publishes (array, or comma-separated) |
|
|
74
|
+
| `providerId` | | | Provider id in OpenCode, default `apigee` |
|
|
75
|
+
| `name` | | | Display name, default `Gemini via Apigee` |
|
|
76
|
+
| `airelay` | | | `false` to ignore AI Relay's config |
|
|
77
|
+
|
|
78
|
+
A model you describe yourself under `provider.apigee.models` in
|
|
79
|
+
`opencode.json` keeps your description; the plugin only adds the rest.
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
- Requests go through OpenCode's own Gemini provider (`@ai-sdk/google`) with
|
|
84
|
+
its base URL set to the gateway's Vertex path,
|
|
85
|
+
`https://<gateway>/v1/projects/<project>/locations/<location>/publishers/google`,
|
|
86
|
+
so each call lands on
|
|
87
|
+
`…/models/<model>:streamGenerateContent` — Vertex's own URL, with the same
|
|
88
|
+
request body.
|
|
89
|
+
- The token comes from `POST <tokenUrl>?grant_type=client_credentials` with the
|
|
90
|
+
client id and secret as HTTP Basic auth, and is cached until a minute before
|
|
91
|
+
it expires. Concurrent requests share one token fetch.
|
|
92
|
+
- Each request has the provider's API-key header replaced by
|
|
93
|
+
`Authorization: Bearer <token>`. If the gateway answers 401, the token is
|
|
94
|
+
fetched again and the request retried once.
|
|
95
|
+
|
|
96
|
+
Tested with OpenCode 1.19.4, end to end against a local stand-in for the
|
|
97
|
+
gateway and its token endpoint.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bun install
|
|
103
|
+
bun test
|
|
104
|
+
bun run build
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
/**
|
|
3
|
+
* Gemini on Vertex AI behind an Apigee gateway, as an OpenCode provider.
|
|
4
|
+
*
|
|
5
|
+
* The provider is `@ai-sdk/google` aimed at the gateway's Vertex publisher
|
|
6
|
+
* path: its request URL is then Vertex's own, and the body is the same
|
|
7
|
+
* `generateContent` JSON. What differs is the credential — an OAuth token from
|
|
8
|
+
* the gateway's client-credentials endpoint instead of a Google API key — and
|
|
9
|
+
* that is the whole of this plugin: a fetch that swaps the one for the other.
|
|
10
|
+
* The client secret is OpenCode's to keep (`opencode auth login` → Apigee).
|
|
11
|
+
*/
|
|
12
|
+
export declare const ApigeePlugin: Plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { baseUrl, displayName, resolveSettings } from "./settings.js";
|
|
2
|
+
import { createApigeeFetch, createTokenSource } from "./token.js";
|
|
3
|
+
// Only the plugin is exported: OpenCode treats exported functions as plugins.
|
|
4
|
+
/** What a Gemini model on Vertex can do; a gateway id OpenCode cannot look up gets these. */
|
|
5
|
+
const GEMINI_LIMITS = { context: 1_048_576, output: 65_536 };
|
|
6
|
+
/**
|
|
7
|
+
* Gemini on Vertex AI behind an Apigee gateway, as an OpenCode provider.
|
|
8
|
+
*
|
|
9
|
+
* The provider is `@ai-sdk/google` aimed at the gateway's Vertex publisher
|
|
10
|
+
* path: its request URL is then Vertex's own, and the body is the same
|
|
11
|
+
* `generateContent` JSON. What differs is the credential — an OAuth token from
|
|
12
|
+
* the gateway's client-credentials endpoint instead of a Google API key — and
|
|
13
|
+
* that is the whole of this plugin: a fetch that swaps the one for the other.
|
|
14
|
+
* The client secret is OpenCode's to keep (`opencode auth login` → Apigee).
|
|
15
|
+
*/
|
|
16
|
+
export const ApigeePlugin = async (_input, rawOptions) => {
|
|
17
|
+
const options = (rawOptions ?? {});
|
|
18
|
+
const providerId = options.providerId?.trim() || "apigee";
|
|
19
|
+
return {
|
|
20
|
+
config: async (config) => {
|
|
21
|
+
const { settings } = resolveSettings(options);
|
|
22
|
+
const models = {};
|
|
23
|
+
for (const id of settings.models) {
|
|
24
|
+
models[id] = {
|
|
25
|
+
name: displayName(id),
|
|
26
|
+
tool_call: true,
|
|
27
|
+
attachment: true,
|
|
28
|
+
reasoning: /gemini-([3-9]|2\.5)/.test(id),
|
|
29
|
+
limit: GEMINI_LIMITS,
|
|
30
|
+
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
config.provider ??= {};
|
|
34
|
+
const existing = (config.provider[providerId] ?? {});
|
|
35
|
+
config.provider[providerId] = {
|
|
36
|
+
name: options.name?.trim() || "Gemini via Apigee",
|
|
37
|
+
npm: "@ai-sdk/google",
|
|
38
|
+
...existing,
|
|
39
|
+
// A model the user described in opencode.json keeps their description.
|
|
40
|
+
models: { ...models, ...(existing.models ?? {}) },
|
|
41
|
+
options: {
|
|
42
|
+
...(settings.gateway && settings.project ? { baseURL: baseUrl(settings) } : {}),
|
|
43
|
+
...(existing.options ?? {}),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
auth: {
|
|
48
|
+
provider: providerId,
|
|
49
|
+
methods: [
|
|
50
|
+
{
|
|
51
|
+
type: "api",
|
|
52
|
+
label: "Apigee client secret",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
loader: async (getAuth) => {
|
|
56
|
+
const stored = await getAuth().catch(() => undefined);
|
|
57
|
+
const secret = stored && stored.type === "api" ? stored.key : undefined;
|
|
58
|
+
const { settings, missing } = resolveSettings(options, process.env, secret);
|
|
59
|
+
if (missing.length)
|
|
60
|
+
throw new Error(`opencode-apigee: missing ${missing.join(", ")}.`);
|
|
61
|
+
const tokens = createTokenSource(settings.tokenUrl, settings.clientId, settings.clientSecret);
|
|
62
|
+
return {
|
|
63
|
+
baseURL: baseUrl(settings),
|
|
64
|
+
// Required by @ai-sdk/google and never sent: the fetch replaces it with the token.
|
|
65
|
+
apiKey: "apigee",
|
|
66
|
+
fetch: createApigeeFetch(tokens),
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Everything needed to reach Gemini on Vertex AI through an Apigee gateway. */
|
|
2
|
+
export interface ApigeeSettings {
|
|
3
|
+
/** Gateway base, e.g. `https://my-gw.example.com`; a bare host gets `https://`. */
|
|
4
|
+
gateway: string;
|
|
5
|
+
project: string;
|
|
6
|
+
location: string;
|
|
7
|
+
tokenUrl: string;
|
|
8
|
+
clientId: string;
|
|
9
|
+
clientSecret: string;
|
|
10
|
+
/** Model ids the gateway publishes. */
|
|
11
|
+
models: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Plugin options, as written in opencode.json: `["opencode-apigee", { ... }]`. */
|
|
14
|
+
export interface ApigeeOptions {
|
|
15
|
+
gateway?: string;
|
|
16
|
+
project?: string;
|
|
17
|
+
location?: string;
|
|
18
|
+
tokenUrl?: string;
|
|
19
|
+
clientId?: string;
|
|
20
|
+
clientSecret?: string;
|
|
21
|
+
models?: string[] | string;
|
|
22
|
+
/** Provider id in OpenCode (default `apigee`). */
|
|
23
|
+
providerId?: string;
|
|
24
|
+
/** Display name in OpenCode (default `Gemini via Apigee`). */
|
|
25
|
+
name?: string;
|
|
26
|
+
/** Read settings from AI Relay's config file when not given here (default true). */
|
|
27
|
+
airelay?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Settings from, in order: the secret OpenCode stored (`opencode auth login`),
|
|
31
|
+
* plugin options, `APIGEE_*` environment variables, and AI Relay's
|
|
32
|
+
* `~/.airelay/config.properties` — so someone already set up with
|
|
33
|
+
* `airelay gemini setup` only adds the plugin.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveSettings(options?: ApigeeOptions, env?: Record<string, string | undefined>, storedSecret?: string, airelay?: Record<string, string>): {
|
|
36
|
+
settings: ApigeeSettings;
|
|
37
|
+
missing: string[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The Vertex publisher path on the gateway. `@ai-sdk/google` appends
|
|
41
|
+
* `/models/<id>:streamGenerateContent`, which together is Vertex's own URL.
|
|
42
|
+
*/
|
|
43
|
+
export declare function baseUrl(s: ApigeeSettings): string;
|
|
44
|
+
/** AI Relay's config file, or nothing. Its path follows AIRELAY_CONFIG like AI Relay itself. */
|
|
45
|
+
export declare function readAirelayConfig(env?: Record<string, string | undefined>): Record<string, string>;
|
|
46
|
+
/**
|
|
47
|
+
* java.util.Properties text: `key=value` or `key:value`, `#`/`!` comments,
|
|
48
|
+
* backslash escapes (`https\://…` is how Java writes a URL) and line
|
|
49
|
+
* continuations.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseProperties(text: string): Record<string, string>;
|
|
52
|
+
/** `gemini-3.1-pro-preview` → "Gemini 3.1 Pro (preview)"; anything else as it is. */
|
|
53
|
+
export declare function displayName(id: string): string;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Settings from, in order: the secret OpenCode stored (`opencode auth login`),
|
|
6
|
+
* plugin options, `APIGEE_*` environment variables, and AI Relay's
|
|
7
|
+
* `~/.airelay/config.properties` — so someone already set up with
|
|
8
|
+
* `airelay gemini setup` only adds the plugin.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveSettings(options = {}, env = process.env, storedSecret, airelay = options.airelay === false ? {} : readAirelayConfig(env)) {
|
|
11
|
+
const pick = (opt, envKey, airelayKey) => nonBlank(opt) ?? nonBlank(env[envKey]) ?? nonBlank(airelay[airelayKey]);
|
|
12
|
+
const models = list(options.models) ?? list(env.APIGEE_MODELS) ?? list(airelay["apigee.agents"]) ?? list(airelay["gemini.model"]) ?? [];
|
|
13
|
+
const settings = {
|
|
14
|
+
gateway: pick(options.gateway, "APIGEE_GATEWAY", "vertex.endpoint") ?? "",
|
|
15
|
+
project: pick(options.project, "APIGEE_PROJECT", "vertex.project") ?? nonBlank(env.GOOGLE_CLOUD_PROJECT) ?? "",
|
|
16
|
+
location: pick(options.location, "APIGEE_LOCATION", "vertex.location") ?? "us-central1",
|
|
17
|
+
tokenUrl: pick(options.tokenUrl, "APIGEE_TOKEN_URL", "apigee.token.url") ?? "",
|
|
18
|
+
clientId: pick(options.clientId, "APIGEE_CLIENT_ID", "apigee.client.id") ?? "",
|
|
19
|
+
clientSecret: nonBlank(storedSecret) ?? pick(options.clientSecret, "APIGEE_CLIENT_SECRET", "apigee.client.secret") ?? "",
|
|
20
|
+
models,
|
|
21
|
+
};
|
|
22
|
+
const missing = [];
|
|
23
|
+
if (!settings.gateway)
|
|
24
|
+
missing.push("gateway (APIGEE_GATEWAY)");
|
|
25
|
+
if (!settings.project)
|
|
26
|
+
missing.push("project (APIGEE_PROJECT)");
|
|
27
|
+
if (!settings.tokenUrl)
|
|
28
|
+
missing.push("tokenUrl (APIGEE_TOKEN_URL)");
|
|
29
|
+
if (!settings.clientId)
|
|
30
|
+
missing.push("clientId (APIGEE_CLIENT_ID)");
|
|
31
|
+
if (!settings.clientSecret)
|
|
32
|
+
missing.push("client secret (`opencode auth login` → Apigee, or APIGEE_CLIENT_SECRET)");
|
|
33
|
+
return { settings, missing };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The Vertex publisher path on the gateway. `@ai-sdk/google` appends
|
|
37
|
+
* `/models/<id>:streamGenerateContent`, which together is Vertex's own URL.
|
|
38
|
+
*/
|
|
39
|
+
export function baseUrl(s) {
|
|
40
|
+
const gateway = /^https?:\/\//i.test(s.gateway) ? s.gateway : `https://${s.gateway}`;
|
|
41
|
+
return `${gateway.replace(/\/+$/, "")}/v1/projects/${encodeURIComponent(s.project)}/locations/${encodeURIComponent(s.location)}/publishers/google`;
|
|
42
|
+
}
|
|
43
|
+
/** AI Relay's config file, or nothing. Its path follows AIRELAY_CONFIG like AI Relay itself. */
|
|
44
|
+
export function readAirelayConfig(env = process.env) {
|
|
45
|
+
const file = nonBlank(env.AIRELAY_CONFIG) ?? join(homedir(), ".airelay", "config.properties");
|
|
46
|
+
try {
|
|
47
|
+
return parseProperties(readFileSync(file, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* java.util.Properties text: `key=value` or `key:value`, `#`/`!` comments,
|
|
55
|
+
* backslash escapes (`https\://…` is how Java writes a URL) and line
|
|
56
|
+
* continuations.
|
|
57
|
+
*/
|
|
58
|
+
export function parseProperties(text) {
|
|
59
|
+
const out = {};
|
|
60
|
+
const lines = text.split(/\r?\n/);
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
let line = lines[i].replace(/^\s+/, "");
|
|
63
|
+
if (!line || line.startsWith("#") || line.startsWith("!"))
|
|
64
|
+
continue;
|
|
65
|
+
while (/(^|[^\\])(\\\\)*\\$/.test(line) && i + 1 < lines.length)
|
|
66
|
+
line = line.slice(0, -1) + lines[++i].replace(/^\s+/, "");
|
|
67
|
+
let k = 0;
|
|
68
|
+
while (k < line.length && !"=: \t".includes(line[k]))
|
|
69
|
+
k += line[k] === "\\" ? 2 : 1;
|
|
70
|
+
const key = unescape(line.slice(0, k));
|
|
71
|
+
let rest = line.slice(k).replace(/^[ \t]*/, "");
|
|
72
|
+
if (rest.startsWith("=") || rest.startsWith(":"))
|
|
73
|
+
rest = rest.slice(1).replace(/^[ \t]*/, "");
|
|
74
|
+
out[key] = unescape(rest);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function unescape(s) {
|
|
79
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, c) => {
|
|
80
|
+
if (c.length === 5)
|
|
81
|
+
return String.fromCharCode(parseInt(c.slice(1), 16));
|
|
82
|
+
return c === "t" ? "\t" : c === "n" ? "\n" : c === "r" ? "\r" : c === "f" ? "\f" : c;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function nonBlank(s) {
|
|
86
|
+
return s && s.trim() ? s.trim() : undefined;
|
|
87
|
+
}
|
|
88
|
+
function list(v) {
|
|
89
|
+
const items = (Array.isArray(v) ? v : (v ?? "").split(",")).map((m) => m.trim()).filter(Boolean);
|
|
90
|
+
return items.length ? [...new Set(items)] : undefined;
|
|
91
|
+
}
|
|
92
|
+
/** `gemini-3.1-pro-preview` → "Gemini 3.1 Pro (preview)"; anything else as it is. */
|
|
93
|
+
export function displayName(id) {
|
|
94
|
+
if (!/^gemini-/i.test(id))
|
|
95
|
+
return id;
|
|
96
|
+
const words = id.split("-");
|
|
97
|
+
const preview = words[words.length - 1].toLowerCase() === "preview";
|
|
98
|
+
const name = (preview ? words.slice(0, -1) : words)
|
|
99
|
+
.map((w) => (/^\d/.test(w) ? w : w.toLowerCase() === "lite" ? "Lite" : w[0].toUpperCase() + w.slice(1)))
|
|
100
|
+
.join(" ")
|
|
101
|
+
.replace("Flash Lite", "Flash-Lite");
|
|
102
|
+
return preview ? `${name} (preview)` : name;
|
|
103
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Apigee OAuth token: client credentials, sent the way AI Relay sends them
|
|
3
|
+
* (`grant_type=client_credentials` on the token URL, HTTP Basic with the client
|
|
4
|
+
* id and secret, empty body), cached until a minute before it expires.
|
|
5
|
+
*/
|
|
6
|
+
export interface TokenSource {
|
|
7
|
+
token(): Promise<string>;
|
|
8
|
+
/** Forget the cached token, after the gateway refused it. */
|
|
9
|
+
invalidate(): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function createTokenSource(tokenUrl: string, clientId: string, clientSecret: string, fetchImpl?: typeof fetch, now?: () => number): TokenSource;
|
|
12
|
+
/**
|
|
13
|
+
* A fetch for `@ai-sdk/google` aimed at the gateway: its `x-goog-api-key`
|
|
14
|
+
* header is replaced by the Apigee bearer token. A 401 means the token went
|
|
15
|
+
* stale early (rotated, revoked): it is fetched again and the request retried
|
|
16
|
+
* once.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createApigeeFetch(tokens: TokenSource, baseFetch?: typeof fetch): typeof fetch;
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function createTokenSource(tokenUrl, clientId, clientSecret, fetchImpl = fetch, now = Date.now) {
|
|
2
|
+
let cached;
|
|
3
|
+
let pending;
|
|
4
|
+
async function request() {
|
|
5
|
+
const url = tokenUrl + (tokenUrl.includes("?") ? "&" : "?") + "grant_type=client_credentials";
|
|
6
|
+
const basic = Buffer.from(`${clientId}:${clientSecret}`, "utf8").toString("base64");
|
|
7
|
+
const res = await fetchImpl(url, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
10
|
+
});
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
throw new Error(`Apigee token request failed (HTTP ${res.status}). Check the token URL, client id and secret.`);
|
|
13
|
+
const body = (await res.json().catch(() => ({})));
|
|
14
|
+
if (typeof body.access_token !== "string" || !body.access_token)
|
|
15
|
+
throw new Error("Apigee token response had no access_token.");
|
|
16
|
+
const expiresIn = Number(body.expires_in) > 0 ? Number(body.expires_in) : 3600;
|
|
17
|
+
cached = { token: body.access_token, expiresAt: now() + Math.max(30, expiresIn - 60) * 1000 };
|
|
18
|
+
return body.access_token;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
async token() {
|
|
22
|
+
if (cached && cached.expiresAt > now())
|
|
23
|
+
return cached.token;
|
|
24
|
+
// Concurrent requests share one token fetch rather than racing several.
|
|
25
|
+
pending ??= request().finally(() => (pending = undefined));
|
|
26
|
+
return pending;
|
|
27
|
+
},
|
|
28
|
+
invalidate() {
|
|
29
|
+
cached = undefined;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A fetch for `@ai-sdk/google` aimed at the gateway: its `x-goog-api-key`
|
|
35
|
+
* header is replaced by the Apigee bearer token. A 401 means the token went
|
|
36
|
+
* stale early (rotated, revoked): it is fetched again and the request retried
|
|
37
|
+
* once.
|
|
38
|
+
*/
|
|
39
|
+
export function createApigeeFetch(tokens, baseFetch = fetch) {
|
|
40
|
+
const send = async (input, init) => {
|
|
41
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
42
|
+
headers.delete("x-goog-api-key");
|
|
43
|
+
headers.set("Authorization", `Bearer ${await tokens.token()}`);
|
|
44
|
+
return baseFetch(input, { ...init, headers });
|
|
45
|
+
};
|
|
46
|
+
const apigeeFetch = async (input, init) => {
|
|
47
|
+
const res = await send(input, init);
|
|
48
|
+
if (res.status !== 401)
|
|
49
|
+
return res;
|
|
50
|
+
tokens.invalidate();
|
|
51
|
+
return send(input, init);
|
|
52
|
+
};
|
|
53
|
+
return apigeeFetch;
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-apigee",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode provider plugin: Gemini on Vertex AI behind an Apigee gateway (OAuth client credentials).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"prepublishOnly": "bun run build && bun test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["opencode", "opencode-plugin", "apigee", "vertex", "gemini", "google"],
|
|
21
|
+
"author": "Chelayel",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/Chelayel/opencode-apigee"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@opencode-ai/plugin": "1.19.4",
|
|
32
|
+
"@types/bun": "^1.3.0",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
}
|
|
36
|
+
}
|