diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 6cfc2286..4c4bec53 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -337,6 +337,7 @@ "imageTagMode": { "type": "string", "enum": ["short", "full"] }, "workbench": { "$ref": "#/$defs/workbenchUiPolicy" }, "observability": { "$ref": "#/$defs/runtimeObservabilityPolicy" }, + "opencode": { "$ref": "#/$defs/opencodeRuntime" }, "runtimeStore": { "$ref": "#/$defs/runtimeStore" }, "workbenchRuntime": { "$ref": "#/$defs/workbenchRuntime" }, "sourceRepo": { "type": "string" }, @@ -368,6 +369,30 @@ }, "additionalProperties": false }, + "opencodeRuntime": { + "type": "object", + "properties": { + "providerProfile": { "type": "string", "minLength": 1 }, + "providerId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }, + "displayName": { "type": "string", "minLength": 1 }, + "model": { "type": "string", "minLength": 1 }, + "smallModel": { "type": "string", "minLength": 1 }, + "baseURL": { "type": "string", "minLength": 1 }, + "npm": { "type": "string", "minLength": 1 }, + "apiKeyEnv": { "type": "string", "pattern": "^[A-Z0-9_]+$" }, + "apiKeySecretRef": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "contextLimit": { "type": "integer", "minimum": 1 }, + "outputLimit": { "type": "integer", "minimum": 1 }, + "agentrunSecretNamespace": { "type": "string", "minLength": 1 }, + "agentrunSecretName": { "type": "string", "minLength": 1 }, + "agentrunSecretKeys": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, "runtimeObservabilityPolicy": { "type": "object", "properties": { diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 2088f0bc..8acfa460 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -335,6 +335,24 @@ lanes: autoCollapseTerminal: false observability: traceExplorerUrlTemplate: /v1/workbench/traces/{trace_id}/events + opencode: + providerProfile: dsflash-go + providerId: dsflash-go + displayName: AgentRun dsflash-go + model: deepseek-v4-flash + smallModel: deepseek-v4-flash + baseURL: https://opencode.ai/zen/go/v1 + npm: '@ai-sdk/openai-compatible' + apiKeyEnv: OPENCODE_DSFLASH_GO_API_KEY + apiKeySecretRef: hwlab-code-agent-provider/opencode-api-key + contextLimit: 1000000 + outputLimit: 384000 + agentrunSecretNamespace: agentrun-v02 + agentrunSecretName: agentrun-v01-provider-dsflash-go + agentrunSecretKeys: + - auth.json + - config.toml + - model-catalog.json externalPostgres: enabled: true serviceName: g14-platform-postgres diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index b2b22920..a5401a56 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -415,6 +415,10 @@ function requiredAccessConfigString(value, label) { return text; } +function optionalObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + async function gitValue(args) { const result = await execFileAsync("git", args, { cwd: repoRoot, timeout: 10000, maxBuffer: 1024 * 1024 }); return result.stdout.trim(); @@ -987,6 +991,13 @@ function secretNameForProfile(secretName, profile) { return secretName; } +function secretRefObjectForProfile(secretRef, profile) { + const ref = configString(secretRef); + const slashIndex = ref.indexOf("/"); + assert.ok(slashIndex > 0 && slashIndex < ref.length - 1, `secretRef ${JSON.stringify(secretRef)} must be name/key`); + return { name: secretNameForProfile(ref.slice(0, slashIndex), profile), key: ref.slice(slashIndex + 1), optional: true }; +} + function rewritePodSecretRefs(podSpec, profile) { if (!podSpec || !isRuntimeLane(profile)) return; for (const volume of podSpec.volumes ?? []) { @@ -996,20 +1007,7 @@ function rewritePodSecretRefs(podSpec, profile) { function deployEnvEntry(name, value, namespace, profile = "dev") { if (typeof value === "string" && value.startsWith("secretRef:")) { - const ref = value.slice("secretRef:".length); - const slashIndex = ref.indexOf("/"); - if (slashIndex > 0 && slashIndex < ref.length - 1) { - return { - name, - valueFrom: { - secretKeyRef: { - name: secretNameForProfile(ref.slice(0, slashIndex), profile), - key: ref.slice(slashIndex + 1), - optional: true - } - } - }; - } + return { name, valueFrom: { secretKeyRef: secretRefObjectForProfile(value.slice("secretRef:".length), profile) } }; } return { name, value: namespaceScopedHost(value, namespace) }; } @@ -5638,7 +5636,81 @@ function secretPlaneIssueLabelValue(issueRef) { return label; } -function opencodeServerManifest({ profile = "v03", source }) { +function opencodeRuntimeConfigForProfile(deploy, profile) { + const config = optionalObject(deploy?.lanes?.[profile]?.opencode); + const providerProfile = configString(config.providerProfile) || "dsflash-go"; + const providerId = configString(config.providerId) || providerProfile; + const model = configString(config.model) || "deepseek-v4-flash"; + const smallModel = configString(config.smallModel) || model; + const apiKeyEnv = configString(config.apiKeyEnv) || opencodeApiKeyEnvName(providerId); + const contextLimit = opencodePositiveInteger(config.contextLimit, 1000000, `deploy.lanes.${profile}.opencode.contextLimit`); + const outputLimit = opencodePositiveInteger(config.outputLimit, 384000, `deploy.lanes.${profile}.opencode.outputLimit`); + return { + providerProfile, + providerId, + displayName: configString(config.displayName) || `AgentRun ${providerProfile}`, + model, + smallModel, + baseURL: configString(config.baseURL) || "https://opencode.ai/zen/go/v1", + npm: configString(config.npm) || "@ai-sdk/openai-compatible", + apiKeyEnv, + apiKeySecretRef: configString(config.apiKeySecretRef) || "hwlab-code-agent-provider/opencode-api-key", + contextLimit, + outputLimit, + agentrunSecretNamespace: configString(config.agentrunSecretNamespace) || "agentrun-v02", + agentrunSecretName: configString(config.agentrunSecretName) || `agentrun-v01-provider-${providerProfile}`, + agentrunSecretKeys: Array.isArray(config.agentrunSecretKeys) && config.agentrunSecretKeys.length > 0 + ? config.agentrunSecretKeys.map(configString).filter(Boolean) + : ["auth.json", "config.toml", "model-catalog.json"] + }; +} + +function opencodePositiveInteger(value, fallback, label) { + if (value === undefined || value === null) return fallback; + assert.ok(Number.isInteger(value) && value > 0, `${label} must be a positive integer`); + return value; +} + +function opencodeApiKeyEnvName(providerId) { + const normalized = String(providerId || "provider").toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, ""); + return `OPENCODE_${normalized || "PROVIDER"}_API_KEY`; +} + +function opencodeConfigContent(config) { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + share: "disabled", + model: `${config.providerId}/${config.model}`, + small_model: `${config.providerId}/${config.smallModel}`, + provider: { + [config.providerId]: { + npm: config.npm, + name: config.displayName, + options: { + baseURL: config.baseURL, + apiKey: `{env:${config.apiKeyEnv}}`, + timeout: 600000, + headerTimeout: 600000, + chunkTimeout: 300000 + }, + models: { + [config.model]: { + name: config.model, + reasoning: true, + tool_call: true, + limit: { + context: config.contextLimit, + output: config.outputLimit + } + } + } + } + } + }); +} + +function opencodeServerManifest({ profile = "v03", source, deploy = null }) { assert.ok(isRuntimeLane(profile), `opencode-server profile must be a runtime lane, got ${profile}`); const namespace = namespaceNameForProfile(profile); const name = "opencode-server"; @@ -5655,9 +5727,22 @@ function opencodeServerManifest({ profile = "v03", source }) { "hwlab.pikastech.local/service-id": name, "hwlab.pikastech.local/source-commit": source.full }; - const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + const opencodeConfig = opencodeRuntimeConfigForProfile(deploy, profile); + const configContent = opencodeConfigContent(opencodeConfig); + const configSha256 = createHash("sha256").update(configContent).digest("hex"); + const apiKeySecretRef = secretRefObjectForProfile(opencodeConfig.apiKeySecretRef, profile); + const annotations = { + "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs", + "hwlab.pikastech.local/opencode-provider-profile": opencodeConfig.providerProfile, + "hwlab.pikastech.local/opencode-provider-id": opencodeConfig.providerId, + "hwlab.pikastech.local/opencode-model": opencodeConfig.model, + "hwlab.pikastech.local/opencode-config-sha256": configSha256, + "hwlab.pikastech.local/opencode-api-key-secret-ref": `${apiKeySecretRef.name}/${apiKeySecretRef.key}`, + "hwlab.pikastech.local/agentrun-provider-secret-ref": `${opencodeConfig.agentrunSecretNamespace}/${opencodeConfig.agentrunSecretName}`, + "hwlab.pikastech.local/agentrun-provider-secret-keys": opencodeConfig.agentrunSecretKeys.join(","), + "hwlab.pikastech.local/values-printed": "false" + }; const authSecretName = secretNameForProfile("hwlab-opencode-server-auth", profile); - const configContent = JSON.stringify({ autoupdate: false, share: "disabled" }); return { apiVersion: "v1", kind: "List", @@ -5711,6 +5796,7 @@ function opencodeServerManifest({ profile = "v03", source }) { { name: "XDG_CONFIG_HOME", value: "/workspace/.config" }, { name: "XDG_DATA_HOME", value: "/workspace/.local/share" }, { name: "OPENCODE_CONFIG_CONTENT", value: configContent }, + { name: opencodeConfig.apiKeyEnv, valueFrom: { secretKeyRef: apiKeySecretRef } }, { name: "OPENCODE_SERVER_USERNAME", valueFrom: { secretKeyRef: { name: authSecretName, key: "username" } } }, { name: "OPENCODE_SERVER_PASSWORD", valueFrom: { secretKeyRef: { name: authSecretName, key: "password" } } } ], @@ -5905,7 +5991,7 @@ async function plannedFiles(args) { if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript })); - if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source })); + if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source, deploy })); if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/node-frpc.yaml`, nodeFrpcManifest({ profile, source })); } diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 50cf28be..b3a1ec04 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -691,6 +691,7 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () const generatedPaths = generatedFiles.map((filePath) => path.relative(outDir, filePath)); assert.ok(generatedPaths.includes("runtime-v03/kustomization.yaml")); assert.ok(generatedPaths.includes("runtime-v03/workloads.yaml")); + assert.ok(generatedPaths.includes("runtime-v03/opencode.yaml")); assert.equal(generatedPaths.includes("runtime-v03/external-secrets.yaml"), true); const kustomization = await readFile(path.join(outDir, "runtime-v03", "kustomization.yaml"), "utf8"); assert.match(kustomization, /external-secrets\.yaml/u); @@ -716,6 +717,24 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () .filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID"); assert.ok(agentRunNodeEnv.length > 0, "expected D518 node id to be inferred from gitops root"); assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D518"]); + + const opencode = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "opencode.yaml"), "utf8")); + const opencodeDeployment = (opencode.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "opencode-server"); + assert.ok(opencodeDeployment, "expected opencode-server deployment"); + assert.equal(opencodeDeployment.metadata?.annotations?.["hwlab.pikastech.local/opencode-provider-profile"], "dsflash-go"); + assert.equal(opencodeDeployment.metadata?.annotations?.["hwlab.pikastech.local/agentrun-provider-secret-ref"], "agentrun-v02/agentrun-v01-provider-dsflash-go"); + assert.equal(opencodeDeployment.metadata?.annotations?.["hwlab.pikastech.local/values-printed"], "false"); + assert.match(opencodeDeployment.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/opencode-config-sha256"] ?? "", /^[a-f0-9]{64}$/u); + const opencodeContainer = collectContainersFromItem(opencodeDeployment).find((container) => container.name === "opencode-server"); + const opencodeEnvEntries = new Map((opencodeContainer?.env ?? []).map((entry) => [entry.name, entry])); + assert.deepEqual(opencodeEnvEntries.get("OPENCODE_DSFLASH_GO_API_KEY")?.valueFrom?.secretKeyRef, { name: "hwlab-v03-code-agent-provider", key: "opencode-api-key", optional: true }); + const opencodeConfig = JSON.parse(opencodeEnvEntries.get("OPENCODE_CONFIG_CONTENT")?.value ?? "{}"); + assert.equal(opencodeConfig.model, "dsflash-go/deepseek-v4-flash"); + assert.equal(opencodeConfig.small_model, "dsflash-go/deepseek-v4-flash"); + assert.equal(opencodeConfig.provider["dsflash-go"].npm, "@ai-sdk/openai-compatible"); + assert.equal(opencodeConfig.provider["dsflash-go"].options.baseURL, "https://opencode.ai/zen/go/v1"); + assert.equal(opencodeConfig.provider["dsflash-go"].options.apiKey, "{env:OPENCODE_DSFLASH_GO_API_KEY}"); + assert.deepEqual(opencodeConfig.provider["dsflash-go"].models["deepseek-v4-flash"].limit, { context: 1000000, output: 384000 }); } finally { await rm(outDir, { recursive: true, force: true }); await rm(scratchDir, { recursive: true, force: true });