diff --git a/dashboard/src/api/settings.ts b/dashboard/src/api/settings.ts index 7efa105c4..121f0f19f 100644 --- a/dashboard/src/api/settings.ts +++ b/dashboard/src/api/settings.ts @@ -135,6 +135,9 @@ export async function updateLlmConfig(config: LlmConfig): Promise apiKey: toSecretPayload(provider.apiKey ?? null), apiType: normalizeLlmApiType(provider.apiType), legacyApi: provider.legacyApi === true ? true : null, + sourceUrl: provider.sourceUrl, + gonkaAddress: provider.gonkaAddress, + endpoints: provider.endpoints, }, ]), ), @@ -154,6 +157,9 @@ export async function addLlmProvider(name: string, config: LlmProviderConfig): P apiKey: toSecretPayload(config.apiKey ?? null), apiType: normalizeLlmApiType(config.apiType), legacyApi: config.legacyApi === true ? true : null, + sourceUrl: config.sourceUrl, + gonkaAddress: config.gonkaAddress, + endpoints: config.endpoints, }; const { data } = await client.post( `/settings/runtime/llm/providers/${name}`, @@ -170,6 +176,9 @@ export async function updateLlmProvider(name: string, config: LlmProviderConfig) apiKey: toSecretPayload(config.apiKey ?? null), apiType: normalizeLlmApiType(config.apiType), legacyApi: config.legacyApi === true ? true : null, + sourceUrl: config.sourceUrl, + gonkaAddress: config.gonkaAddress, + endpoints: config.endpoints, }; const { data } = await client.put( `/settings/runtime/llm/providers/${name}`, diff --git a/dashboard/src/api/settingsApiUtils.ts b/dashboard/src/api/settingsApiUtils.ts index a912990b7..46d6bde63 100644 --- a/dashboard/src/api/settingsApiUtils.ts +++ b/dashboard/src/api/settingsApiUtils.ts @@ -10,7 +10,7 @@ export function toSecretPayload(value: string | null | undefined): SecretPayload return { value, encrypted: false }; } -const SUPPORTED_LLM_API_TYPES = ['openai', 'anthropic', 'gemini'] as const; +const SUPPORTED_LLM_API_TYPES = ['openai', 'anthropic', 'gemini', 'gonka'] as const; type SupportedLlmApiType = (typeof SUPPORTED_LLM_API_TYPES)[number]; function isSupportedLlmApiType(value: string): value is SupportedLlmApiType { diff --git a/dashboard/src/api/settingsRuntimeMappers.ts b/dashboard/src/api/settingsRuntimeMappers.ts index b4803cc1c..729f0d05f 100644 --- a/dashboard/src/api/settingsRuntimeMappers.ts +++ b/dashboard/src/api/settingsRuntimeMappers.ts @@ -1,6 +1,6 @@ import { toModelRegistryConfig, toModelRouterConfig } from './settingsModelMappers'; import { toSelfEvolvingConfig } from './settingsSelfEvolvingMappers'; -import type { RuntimeConfig } from './settingsTypes'; +import type { GonkaEndpointConfig, RuntimeConfig } from './settingsTypes'; import { hasSecretValue, type UnknownRecord } from './settingsUtils'; import { normalizeLlmApiType, toSecretPayload } from './settingsApiUtils'; @@ -70,6 +70,9 @@ function normalizeLlmProviders(providers: Record): Record apiKeyPresent: hasSecretValue(provider.apiKey as UnknownRecord | undefined), apiType: normalizeLlmApiType(provider.apiType), legacyApi: provider.legacyApi === true ? true : null, + sourceUrl: typeof provider.sourceUrl === 'string' ? provider.sourceUrl : null, + gonkaAddress: typeof provider.gonkaAddress === 'string' ? provider.gonkaAddress : null, + endpoints: toGonkaEndpoints(provider.endpoints), }])); } @@ -95,9 +98,27 @@ function toBackendLlmProviders(providers: RuntimeConfig['llm']['providers']): Un apiKey: toSecretPayload(provider.apiKey ?? null), apiType: normalizeLlmApiType(provider.apiType), legacyApi: provider.legacyApi === true ? true : null, + sourceUrl: provider.sourceUrl, + gonkaAddress: provider.gonkaAddress, + endpoints: provider.endpoints, }])); } +function toGonkaEndpoints(value: unknown): GonkaEndpointConfig[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (entry == null || typeof entry !== 'object') { + return []; + } + const record = entry as UnknownRecord; + const url = typeof record.url === 'string' ? record.url.trim() : ''; + const transferAddress = typeof record.transferAddress === 'string' ? record.transferAddress.trim() : ''; + return url.length > 0 && transferAddress.length > 0 ? [{ url, transferAddress }] : []; + }); +} + function toBackendSelfEvolvingConfig(selfEvolving: UnknownRecord): UnknownRecord { const tactics = selfEvolving.tactics as UnknownRecord | undefined; const search = tactics?.search as UnknownRecord | undefined; diff --git a/dashboard/src/api/settingsTypes.ts b/dashboard/src/api/settingsTypes.ts index 1c8142c22..2ec8f92a9 100644 --- a/dashboard/src/api/settingsTypes.ts +++ b/dashboard/src/api/settingsTypes.ts @@ -1,6 +1,6 @@ import type { ExplicitModelTierId } from '../lib/modelTiers'; -export type ApiType = 'openai' | 'anthropic' | 'gemini'; +export type ApiType = 'openai' | 'anthropic' | 'gemini' | 'gonka'; export interface RuntimeConfig { telegram: TelegramConfig; @@ -27,7 +27,8 @@ export interface RuntimeConfig { export interface ModelRegistryConfig { repositoryUrl: string | null; branch: string | null; } export interface LlmConfig { providers: Record; } -export interface LlmProviderConfig { apiKey: string | null; apiKeyPresent?: boolean; baseUrl: string | null; requestTimeoutSeconds: number | null; apiType: ApiType | null; legacyApi: boolean | null; } +export interface GonkaEndpointConfig { url: string; transferAddress: string; } +export interface LlmProviderConfig { apiKey: string | null; apiKeyPresent?: boolean; baseUrl: string | null; requestTimeoutSeconds: number | null; apiType: ApiType | null; legacyApi: boolean | null; sourceUrl: string | null; gonkaAddress: string | null; endpoints: GonkaEndpointConfig[]; } export interface MemoryConfig { enabled: boolean | null; softPromptBudgetTokens: number | null; maxPromptBudgetTokens: number | null; workingTopK: number | null; episodicTopK: number | null; semanticTopK: number | null; proceduralTopK: number | null; promotionEnabled: boolean | null; promotionMinConfidence: number | null; decayEnabled: boolean | null; decayDays: number | null; retrievalLookbackDays: number | null; codeAwareExtractionEnabled: boolean | null; disclosure?: MemoryDisclosureConfig | null; diagnostics?: MemoryDiagnosticsConfig | null; } export interface MemoryPreset { id: string; label: string; comment: string; memory: MemoryConfig; } export type MemoryDisclosureMode = 'index' | 'summary' | 'selective_detail' | 'full_pack'; diff --git a/dashboard/src/pages/settings/LlmProviderBaseUrlField.tsx b/dashboard/src/pages/settings/LlmProviderBaseUrlField.tsx index 2314e0166..811640899 100644 --- a/dashboard/src/pages/settings/LlmProviderBaseUrlField.tsx +++ b/dashboard/src/pages/settings/LlmProviderBaseUrlField.tsx @@ -15,7 +15,7 @@ export function LlmProviderBaseUrlField({ name, form, onFormChange }: LlmProvide const hasBaseUrl = (form.baseUrl ?? '').trim().length > 0; const suggestedBaseUrl = getSuggestedBaseUrl(name, apiType); const shouldShowUseDefaultBaseUrl = suggestedBaseUrl != null && form.baseUrl !== suggestedBaseUrl; - const shouldShowClearBaseUrl = apiType === 'gemini' && hasBaseUrl; + const shouldShowClearBaseUrl = (apiType === 'gemini' || apiType === 'gonka') && hasBaseUrl; return ( @@ -41,9 +41,11 @@ export function LlmProviderBaseUrlField({ name, form, onFormChange }: LlmProvide {apiType === 'gemini' ? 'Gemini uses the native Google endpoint, so Base URL is usually left empty.' - : suggestedBaseUrl != null - ? `Recommended endpoint: ${suggestedBaseUrl}` - : 'Leave empty to use the provider default endpoint.'} + : apiType === 'gonka' + ? 'Optional: direct /v1 endpoint for model discovery. Runtime requests use Gonka Source URL or endpoints below.' + : suggestedBaseUrl != null + ? `Recommended endpoint: ${suggestedBaseUrl}` + : 'Leave empty to use the provider default endpoint.'} ); diff --git a/dashboard/src/pages/settings/LlmProviderEditorCard.tsx b/dashboard/src/pages/settings/LlmProviderEditorCard.tsx index 67c73ad95..6f7cd32dc 100644 --- a/dashboard/src/pages/settings/LlmProviderEditorCard.tsx +++ b/dashboard/src/pages/settings/LlmProviderEditorCard.tsx @@ -10,6 +10,7 @@ import { } from './llmProvidersSupport'; import { LlmProviderBaseUrlField } from './LlmProviderBaseUrlField'; import { LlmProviderSecretField } from './LlmProviderSecretField'; +import { LlmProviderGonkaFields } from './LlmProviderGonkaFields'; export interface LlmProviderEditorCardProps { name: string; @@ -79,6 +80,9 @@ export function LlmProviderEditorCard({ + {apiType === 'gonka' && ( + + )} {apiType === 'openai' && ( diff --git a/dashboard/src/pages/settings/LlmProviderGonkaFields.tsx b/dashboard/src/pages/settings/LlmProviderGonkaFields.tsx new file mode 100644 index 000000000..582351a99 --- /dev/null +++ b/dashboard/src/pages/settings/LlmProviderGonkaFields.tsx @@ -0,0 +1,97 @@ +import type { ReactElement } from 'react'; +import { Button, Col, Form, Row } from 'react-bootstrap'; + +import type { LlmProviderConfig } from '../../api/settingsTypes'; +import { toNullableString } from './llmProvidersSupport'; + +export interface LlmProviderGonkaFieldsProps { + form: LlmProviderConfig; + onFormChange: (form: LlmProviderConfig) => void; +} + +export function LlmProviderGonkaFields({ form, onFormChange }: LlmProviderGonkaFieldsProps): ReactElement { + const endpointsText = serializeEndpoints(form.endpoints); + + return ( + +
+ + + + Gonka Source URL + onFormChange({ ...form, sourceUrl: toNullableString(event.target.value) })} + placeholder="https://node3.gonka.ai" + /> + + Used to discover active transfer-agent endpoints when no explicit endpoints are set. + + + + + + Requester Address + onFormChange({ ...form, gonkaAddress: toNullableString(event.target.value) })} + placeholder="Optional; derived from private key when empty" + /> + + + + + Explicit Endpoints + onFormChange({ ...form, endpoints: parseEndpoints(event.target.value) })} + placeholder="https://host/v1;gonka1transferaddress" + /> + + Optional comma/newline-separated pairs in url;transferAddress format. Overrides Source URL discovery. + + + + + + + +
+ + ); +} + +function serializeEndpoints(endpoints: LlmProviderConfig['endpoints']): string { + return (endpoints ?? []) + .map((endpoint) => `${endpoint.url};${endpoint.transferAddress}`) + .join('\n'); +} + +function parseEndpoints(value: string): LlmProviderConfig['endpoints'] { + return value + .split(/[\n,]/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .flatMap((entry) => { + const parts = entry.split(';'); + if (parts.length !== 2) { + return []; + } + const url = parts[0].trim(); + const transferAddress = parts[1].trim(); + return url.length > 0 && transferAddress.length > 0 ? [{ url, transferAddress }] : []; + }); +} diff --git a/dashboard/src/pages/settings/LlmProvidersTab.test.tsx b/dashboard/src/pages/settings/LlmProvidersTab.test.tsx index 4fa75d280..f3ad7ca34 100644 --- a/dashboard/src/pages/settings/LlmProvidersTab.test.tsx +++ b/dashboard/src/pages/settings/LlmProvidersTab.test.tsx @@ -18,6 +18,9 @@ const config: LlmConfig = { requestTimeoutSeconds: 30, apiType: 'openai', legacyApi: null, + sourceUrl: null, + gonkaAddress: null, + endpoints: [], }, }, }; diff --git a/dashboard/src/pages/settings/LlmProvidersTab.tsx b/dashboard/src/pages/settings/LlmProvidersTab.tsx index d429f6847..c2ccf9426 100644 --- a/dashboard/src/pages/settings/LlmProvidersTab.tsx +++ b/dashboard/src/pages/settings/LlmProvidersTab.tsx @@ -88,7 +88,15 @@ export default function LlmProvidersTab({ config, modelRouter }: LlmProvidersTab return; } setEditingName(name); - setEditForm({ ...provider, apiKey: null, apiType: normalizeApiType(provider.apiType), legacyApi: provider.legacyApi ?? null }); + setEditForm({ + ...provider, + apiKey: null, + apiType: normalizeApiType(provider.apiType), + legacyApi: provider.legacyApi ?? null, + sourceUrl: provider.sourceUrl ?? null, + gonkaAddress: provider.gonkaAddress ?? null, + endpoints: provider.endpoints ?? [], + }); setIsNewProvider(false); setShowKey(false); }; diff --git a/dashboard/src/pages/settings/ModelCatalogTab.test.tsx b/dashboard/src/pages/settings/ModelCatalogTab.test.tsx index 30761f169..a91e30928 100644 --- a/dashboard/src/pages/settings/ModelCatalogTab.test.tsx +++ b/dashboard/src/pages/settings/ModelCatalogTab.test.tsx @@ -20,6 +20,9 @@ const llmConfig: LlmConfig = { requestTimeoutSeconds: null, apiType: 'openai', legacyApi: null, + sourceUrl: null, + gonkaAddress: null, + endpoints: [], }, openrouter: { apiKey: null, @@ -28,6 +31,9 @@ const llmConfig: LlmConfig = { requestTimeoutSeconds: 30, apiType: 'openai', legacyApi: null, + sourceUrl: null, + gonkaAddress: null, + endpoints: [], }, }, }; diff --git a/dashboard/src/pages/settings/ModelsTab.test.tsx b/dashboard/src/pages/settings/ModelsTab.test.tsx index 8cc275307..3d8a8f99e 100644 --- a/dashboard/src/pages/settings/ModelsTab.test.tsx +++ b/dashboard/src/pages/settings/ModelsTab.test.tsx @@ -46,6 +46,9 @@ const llmConfig: LlmConfig = { requestTimeoutSeconds: null, apiType: 'openai', legacyApi: null, + sourceUrl: null, + gonkaAddress: null, + endpoints: [], }, anthropic: { apiKey: null, @@ -54,6 +57,9 @@ const llmConfig: LlmConfig = { requestTimeoutSeconds: null, apiType: 'anthropic', legacyApi: null, + sourceUrl: null, + gonkaAddress: null, + endpoints: [], }, openrouter: { apiKey: null, @@ -62,6 +68,9 @@ const llmConfig: LlmConfig = { requestTimeoutSeconds: 30, apiType: 'openai', legacyApi: null, + sourceUrl: null, + gonkaAddress: null, + endpoints: [], }, }, }; diff --git a/dashboard/src/pages/settings/llmProvidersSupport.ts b/dashboard/src/pages/settings/llmProvidersSupport.ts index 0905ed58f..9adc9b0bf 100644 --- a/dashboard/src/pages/settings/llmProvidersSupport.ts +++ b/dashboard/src/pages/settings/llmProvidersSupport.ts @@ -24,6 +24,7 @@ export const KNOWN_BASE_URLS: Record = { qwen: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', cerebras: 'https://api.cerebras.ai/v1', deepinfra: 'https://api.deepinfra.com/v1/openai', + gonka: 'https://node3.gonka.ai/v1', }; export const KNOWN_PROVIDERS: string[] = Object.keys(KNOWN_BASE_URLS); @@ -32,9 +33,10 @@ export const PROVIDER_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; const KNOWN_API_TYPES: Record = { anthropic: 'anthropic', google: 'gemini', + gonka: 'gonka', }; -export const API_TYPE_OPTIONS: ApiType[] = ['openai', 'anthropic', 'gemini']; +export const API_TYPE_OPTIONS: ApiType[] = ['openai', 'anthropic', 'gemini', 'gonka']; export const API_TYPE_DETAILS: Record = { openai: { @@ -55,6 +57,12 @@ export const API_TYPE_DETAILS: Record = { badgeBg: 'success-subtle', badgeText: 'success', }, + gonka: { + label: 'Gonka', + help: 'Gonka protocol: OpenAI chat completions with ECDSA request signing.', + badgeBg: 'dark', + badgeText: 'light', + }, }; export function toNullableString(value: string): string | null { @@ -82,7 +90,7 @@ export function getDefaultApiTypeForProvider(name: string): ApiType { } export function getSuggestedBaseUrl(name: string, apiType: ApiType): string | null { - if (apiType === 'gemini') { + if (apiType === 'gemini' || apiType === 'gonka') { return null; } @@ -103,5 +111,8 @@ export function buildDefaultProviderConfig(name: string): LlmProviderConfig { requestTimeoutSeconds: 300, apiType: defaultApiType, legacyApi: null, + sourceUrl: defaultApiType === 'gonka' ? 'https://node3.gonka.ai' : null, + gonkaAddress: null, + endpoints: [], }; } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index da43efe4d..0ac3c5d66 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -247,7 +247,7 @@ Configure provider credentials under `llm.providers`. - Provider key is the model prefix in `provider/model` (and should match `provider` in `models/models.json`). - `apiType` selects the wire protocol used by the adapter. -- Supported `apiType` values: `openai` (default), `anthropic`, `gemini`. +- Supported `apiType` values: `openai` (default), `anthropic`, `gemini`, `gonka`. ```json { @@ -266,6 +266,13 @@ Configure provider credentials under `llm.providers`. "google": { "apiKey": "AIza...", "apiType": "gemini" + }, + "gonka": { + "apiKey": "0x...secp256k1-private-key...", + "apiType": "gonka", + "sourceUrl": "https://node3.gonka.ai", + "gonkaAddress": null, + "endpoints": [] } } } @@ -275,7 +282,7 @@ Configure provider credentials under `llm.providers`. Notes: - Use lowercase `apiType` values. -- `baseUrl` is optional; for `gemini` it is typically left empty. +- `baseUrl` is optional; for `gemini` it is typically left empty. For `gonka`, use `sourceUrl` for participant discovery or `endpoints` as `url` + `transferAddress` pairs. - These same provider profiles are used by the dashboard `Model Catalog` for live model discovery via `/api/models/discover/{provider}`. ### Model Configuration diff --git a/docs/MODEL_ROUTING.md b/docs/MODEL_ROUTING.md index b8157657c..64656e4c6 100644 --- a/docs/MODEL_ROUTING.md +++ b/docs/MODEL_ROUTING.md @@ -243,7 +243,7 @@ Configure provider API keys in `preferences/runtime-config.json`: The `Langchain4jAdapter` creates per-request model instances when the requested model differs from the default. - Provider config lookup is still based on the model prefix (`provider/model`). -- Protocol dispatch is controlled by `llm.providers..apiType` (`openai`, `anthropic`, `gemini`). +- Protocol dispatch is controlled by `llm.providers..apiType` (`openai`, `anthropic`, `gemini`, `gonka`). This decouples provider naming from wire protocol, so custom provider keys can still use the correct adapter. diff --git a/pom.xml b/pom.xml index 462c48edd..3cc2dc127 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ 1.7.1 4.34.1 0.6.1 + 1.81 4.9.8.3 3.28.0 @@ -191,6 +192,13 @@ ${langchain4j.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + com.fasterxml.jackson.core diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaConfiguration.java b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaConfiguration.java new file mode 100644 index 000000000..2b20b5fb3 --- /dev/null +++ b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaConfiguration.java @@ -0,0 +1,13 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GonkaConfiguration { + + @Bean + public static GonkaRequestSigner gonkaRequestSigner() { + return new GonkaRequestSigner(); + } +} diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolutionAdapter.java b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolutionAdapter.java new file mode 100644 index 000000000..cc09d6918 --- /dev/null +++ b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolutionAdapter.java @@ -0,0 +1,199 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class GonkaEndpointResolutionAdapter implements GonkaEndpointResolver { + + private static final String CHAIN_PARAMS_PATH = "/chain-api/productscience/inference/inference/params"; + private static final String CURRENT_PARTICIPANTS_PATH = "/v1/epochs/current/participants"; + private static final String IDENTITY_PATH = "/v1/identity"; + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public GonkaResolvedEndpoint resolve(GonkaEndpointResolutionRequest request) { + if (request == null) { + throw new IllegalArgumentException("Gonka endpoint resolution request is required"); + } + if (request.configuredEndpoints() != null && !request.configuredEndpoints().isEmpty()) { + return toResolvedEndpoint(request.configuredEndpoints().getFirst()); + } + if (request.sourceUri() == null) { + throw new IllegalArgumentException("Gonka provider requires sourceUrl or endpoints"); + } + Duration timeout = resolveTimeout(request.timeout()); + List endpoints = discoverTransferAgentEndpoints(request.sourceUri(), timeout); + if (endpoints.isEmpty()) { + throw new IllegalStateException("No Gonka endpoints discovered from sourceUrl: " + request.sourceUri()); + } + return resolveDelegateEndpointOrDefault(endpoints.getFirst(), timeout); + } + + protected HttpClient buildHttpClient(Duration timeout) { + return HttpClient.newBuilder() + .connectTimeout(timeout) + .build(); + } + + private Duration resolveTimeout(Duration timeout) { + return timeout != null ? timeout : Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS); + } + + private GonkaResolvedEndpoint toResolvedEndpoint(GonkaConfiguredEndpoint endpoint) { + if (endpoint == null || isBlank(endpoint.url()) || isBlank(endpoint.transferAddress())) { + throw new IllegalArgumentException("Gonka endpoints must include url and transferAddress"); + } + return new GonkaResolvedEndpoint(ensureV1(endpoint.url()), endpoint.transferAddress().trim()); + } + + private List discoverTransferAgentEndpoints(URI sourceUri, Duration timeout) { + Set allowedAddresses = fetchAllowedTransferAddresses(sourceUri, timeout); + List participantEndpoints = fetchParticipants(sourceUri, timeout); + if (allowedAddresses.isEmpty()) { + return participantEndpoints; + } + return participantEndpoints.stream() + .filter(endpoint -> allowedAddresses.contains(endpoint.transferAddress())) + .toList(); + } + + private Set fetchAllowedTransferAddresses(URI sourceUri, Duration timeout) { + JsonNode root = getJson(resolveBaseUri(sourceUri).resolve(CHAIN_PARAMS_PATH), timeout); + JsonNode addressesNode = root.path("params") + .path("transfer_agent_access_params") + .path("allowed_transfer_addresses"); + Set addresses = new LinkedHashSet<>(); + if (addressesNode.isArray()) { + for (JsonNode addressNode : addressesNode) { + String address = textValue(addressNode); + if (!isBlank(address)) { + addresses.add(address); + } + } + } + return addresses; + } + + private List fetchParticipants(URI sourceUri, Duration timeout) { + JsonNode root = getJson(resolveBaseUri(sourceUri).resolve(CURRENT_PARTICIPANTS_PATH), timeout); + Set excluded = readExcludedParticipants(root.path("excluded_participants")); + JsonNode participantsNode = root.path("active_participants").path("participants"); + List endpoints = new ArrayList<>(); + if (!participantsNode.isArray()) { + return endpoints; + } + for (JsonNode participantNode : participantsNode) { + String inferenceUrl = textValue(participantNode.path("inference_url")); + String address = textValue(participantNode.path("index")); + if (!isBlank(inferenceUrl) && !isBlank(address) && !excluded.contains(address)) { + endpoints.add(new GonkaResolvedEndpoint(ensureV1(inferenceUrl), address)); + } + } + return endpoints; + } + + private Set readExcludedParticipants(JsonNode excludedParticipantsNode) { + Set excluded = new LinkedHashSet<>(); + if (!excludedParticipantsNode.isArray()) { + return excluded; + } + for (JsonNode excludedNode : excludedParticipantsNode) { + String address = textValue(excludedNode.path("address")); + if (!isBlank(address)) { + excluded.add(address); + } + } + return excluded; + } + + private GonkaResolvedEndpoint resolveDelegateEndpointOrDefault(GonkaResolvedEndpoint selectedEndpoint, + Duration timeout) { + try { + JsonNode root = getJson(resolveBaseUri(URI.create(selectedEndpoint.url())).resolve(IDENTITY_PATH), timeout); + JsonNode delegateTa = root.path("data").path("delegate_ta"); + if (!delegateTa.isObject() || delegateTa.isEmpty()) { + return selectedEndpoint; + } + java.util.Iterator> fields = delegateTa.fields(); + if (!fields.hasNext()) { + return selectedEndpoint; + } + String delegateUrl = fields.next().getKey(); + return new GonkaResolvedEndpoint(ensureV1(delegateUrl), selectedEndpoint.transferAddress()); + } catch (RuntimeException exception) { // NOSONAR - delegate endpoint discovery is optional. + return selectedEndpoint; + } + } + + private JsonNode getJson(URI uri, Duration timeout) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(uri) + .timeout(timeout) + .header("Accept", "application/json") + .GET() + .build(); + try { + HttpResponse response = buildHttpClient(timeout).send( + httpRequest, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("Gonka endpoint discovery request failed with status " + + response.statusCode()); + } + return objectMapper.readTree(response.body()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Gonka endpoint discovery request was interrupted", exception); + } catch (IOException exception) { + throw new IllegalStateException("Gonka endpoint discovery request failed: " + exception.getMessage(), + exception); + } + } + + private URI resolveBaseUri(URI uri) { + String value = uri.toString().trim(); + if (value.endsWith("/v1")) { + value = value.substring(0, value.length() - 3); + } + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return URI.create(value + "/"); + } + + private String ensureV1(String url) { + String normalized = url.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized.endsWith("/v1") ? normalized : normalized + "/v1"; + } + + private String textValue(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + String value = node.asText(); + return isBlank(value) ? null : value; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolver.java b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolver.java new file mode 100644 index 000000000..78752808a --- /dev/null +++ b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolver.java @@ -0,0 +1,20 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import java.net.URI; +import java.time.Duration; +import java.util.List; + +public interface GonkaEndpointResolver { + + GonkaResolvedEndpoint resolve(GonkaEndpointResolutionRequest request); + + record GonkaEndpointResolutionRequest(URI sourceUri, List configuredEndpoints, + Duration timeout) { + } + + record GonkaConfiguredEndpoint(String url, String transferAddress) { + } + + record GonkaResolvedEndpoint(String url, String transferAddress) { + } +} diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaHttpClientBuilderFactory.java b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaHttpClientBuilderFactory.java new file mode 100644 index 000000000..0f40bcbf1 --- /dev/null +++ b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaHttpClientBuilderFactory.java @@ -0,0 +1,71 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import dev.langchain4j.http.client.HttpClientBuilder; +import dev.langchain4j.http.client.HttpClientBuilderLoader; +import java.time.Duration; +import java.util.List; +import me.golemcore.bot.domain.model.RuntimeConfig; +import me.golemcore.bot.domain.model.Secret; +import org.springframework.stereotype.Component; + +@Component +public class GonkaHttpClientBuilderFactory { + + private final GonkaEndpointResolver endpointResolutionPort; + private final GonkaRequestSigner signer; + + public GonkaHttpClientBuilderFactory(GonkaEndpointResolver endpointResolutionPort, + GonkaRequestSigner signer) { + this.endpointResolutionPort = endpointResolutionPort; + this.signer = signer; + } + + public ResolvedGonkaHttpClientBuilder create(RuntimeConfig.LlmProviderConfig config, Duration timeout) { + String privateKey = Secret.valueOrEmpty(config.getApiKey()); + if (privateKey.isBlank()) { + throw new IllegalStateException("Missing apiKey for Gonka provider in runtime config"); + } + GonkaEndpointResolver.GonkaResolvedEndpoint endpoint = endpointResolutionPort.resolve( + new GonkaEndpointResolver.GonkaEndpointResolutionRequest( + config.getSourceUri(), + toConfiguredEndpoints(config.getEndpoints()), + timeout)); + HttpClientBuilder baseBuilder = HttpClientBuilderLoader.loadHttpClientBuilder(); + if (baseBuilder == null) { + baseBuilder = instantiateJdkHttpClientBuilder(); + } + baseBuilder.connectTimeout(timeout); + baseBuilder.readTimeout(timeout); + HttpClientBuilder signingBuilder = new GonkaSigningHttpClientBuilder( + baseBuilder, + signer, + privateKey, + config.getGonkaAddress(), + endpoint.transferAddress()); + return new ResolvedGonkaHttpClientBuilder(signingBuilder, endpoint.url()); + } + + private List toConfiguredEndpoints( + List endpoints) { + if (endpoints == null || endpoints.isEmpty()) { + return List.of(); + } + return endpoints.stream() + .map(endpoint -> new GonkaEndpointResolver.GonkaConfiguredEndpoint( + endpoint.getUrl(), endpoint.getTransferAddress())) + .toList(); + } + + private HttpClientBuilder instantiateJdkHttpClientBuilder() { + try { + Class builderClass = Class.forName("dev.langchain4j.http.client.jdk.JdkHttpClientBuilder"); + return (HttpClientBuilder) builderClass.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException | LinkageError exception) { + throw new IllegalStateException("No HttpClientBuilder implementation available for Gonka provider", + exception); + } + } + + public record ResolvedGonkaHttpClientBuilder(HttpClientBuilder httpClientBuilder, String baseUrl) { + } +} diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaRequestSigner.java b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaRequestSigner.java new file mode 100644 index 000000000..93414ea1f --- /dev/null +++ b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaRequestSigner.java @@ -0,0 +1,256 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.time.Clock; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicLong; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.digests.RIPEMD160Digest; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPrivateKeyParameters; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.crypto.signers.HMacDSAKCalculator; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.math.ec.ECPoint; + +public class GonkaRequestSigner { + + private static final String GONKA_ADDRESS_PREFIX = "gonka"; + private static final String SECP256K1_CURVE = "secp256k1"; + private static final char[] BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".toCharArray(); + private static final int BECH32_GENERATOR_0 = 0x3b6a57b2; + private static final int BECH32_GENERATOR_1 = 0x26508e6d; + private static final int BECH32_GENERATOR_2 = 0x1ea119fa; + private static final int BECH32_GENERATOR_3 = 0x3d4233dd; + private static final int BECH32_GENERATOR_4 = 0x2a1462b3; + private static final int BECH32_CHECKSUM_LENGTH = 6; + private static final int SIGNATURE_PART_LENGTH = 32; + private static final int SIGNATURE_LENGTH = 64; + private static final BigInteger HALF_CURVE_ORDER; + private static final X9ECParameters CURVE_PARAMETERS; + private static final ECDomainParameters DOMAIN_PARAMETERS; + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + CURVE_PARAMETERS = CustomNamedCurves.getByName(SECP256K1_CURVE); + DOMAIN_PARAMETERS = new ECDomainParameters( + CURVE_PARAMETERS.getCurve(), + CURVE_PARAMETERS.getG(), + CURVE_PARAMETERS.getN(), + CURVE_PARAMETERS.getH()); + HALF_CURVE_ORDER = CURVE_PARAMETERS.getN().shiftRight(1); + } + + private final Clock clock; + private final AtomicLong lastTimestampNanos = new AtomicLong(); + + public GonkaRequestSigner() { + this(Clock.systemUTC()); + } + + GonkaRequestSigner(Clock clock) { + this.clock = clock; + } + + public SignedRequest sign(String payload, String privateKeyHex, String requesterAddress, String transferAddress) { + if (isBlank(privateKeyHex)) { + throw new IllegalArgumentException("Gonka private key is required"); + } + if (isBlank(transferAddress)) { + throw new IllegalArgumentException("Gonka transferAddress is required"); + } + String resolvedAddress = !isBlank(requesterAddress) ? requesterAddress.trim() : deriveAddress(privateKeyHex); + long timestamp = currentTimestampNanos(); + String signature = createSignature(payload != null ? payload : "", privateKeyHex, timestamp, + transferAddress.trim()); + return new SignedRequest(signature, resolvedAddress, Long.toString(timestamp)); + } + + String createSignature(String payload, String privateKeyHex, long timestamp, String transferAddress) { + byte[] payloadHash = sha256(payload.getBytes(StandardCharsets.UTF_8)); + String payloadHashHex = HexFormat.of().formatHex(payloadHash); + byte[] signatureInput = (payloadHashHex + timestamp + transferAddress).getBytes(StandardCharsets.UTF_8); + byte[] signatureHash = sha256(signatureInput); + BigInteger privateKey = new BigInteger(1, parsePrivateKey(privateKeyHex)); + ECDSASigner signer = new ECDSASigner( + new HMacDSAKCalculator(new org.bouncycastle.crypto.digests.SHA256Digest())); + signer.init(true, new ECPrivateKeyParameters(privateKey, DOMAIN_PARAMETERS)); + BigInteger[] signature = signer.generateSignature(signatureHash); + BigInteger r = signature[0]; + BigInteger s = normalizeLowS(signature[1]); + byte[] rawSignature = new byte[SIGNATURE_LENGTH]; + copyPart(toFixedLength(r), rawSignature, 0); + copyPart(toFixedLength(s), rawSignature, SIGNATURE_PART_LENGTH); + return java.util.Base64.getEncoder().encodeToString(rawSignature); + } + + String deriveAddress(String privateKeyHex) { + BigInteger privateKey = new BigInteger(1, parsePrivateKey(privateKeyHex)); + ECPoint publicKey = CURVE_PARAMETERS.getG().multiply(privateKey).normalize(); + byte[] compressedPublicKey = publicKey.getEncoded(true); + byte[] shaHash = sha256(compressedPublicKey); + byte[] ripemdHash = ripemd160(shaHash); + return encodeBech32(GONKA_ADDRESS_PREFIX, convertBits(ripemdHash, 8, 5, true)); + } + + private long currentTimestampNanos() { + long wallClockNanos = Math.addExact( + Math.multiplyExact(clock.instant().getEpochSecond(), 1_000_000_000L), + clock.instant().getNano()); + return lastTimestampNanos.updateAndGet(previous -> wallClockNanos > previous ? wallClockNanos : previous + 1); + } + + private BigInteger normalizeLowS(BigInteger s) { + return s.compareTo(HALF_CURVE_ORDER) > 0 ? CURVE_PARAMETERS.getN().subtract(s) : s; + } + + private byte[] parsePrivateKey(String privateKeyHex) { + String clean = privateKeyHex.trim(); + if (clean.startsWith("0x") || clean.startsWith("0X")) { + clean = clean.substring(2); + } + if (clean.length() != SIGNATURE_LENGTH) { + throw new IllegalArgumentException("Gonka private key must be a 32-byte hex value"); + } + return HexFormat.of().parseHex(clean); + } + + private byte[] toFixedLength(BigInteger value) { + byte[] bytes = value.toByteArray(); + if (bytes.length == SIGNATURE_PART_LENGTH) { + return bytes; + } + byte[] fixed = new byte[SIGNATURE_PART_LENGTH]; + if (bytes.length > SIGNATURE_PART_LENGTH) { + System.arraycopy(bytes, bytes.length - SIGNATURE_PART_LENGTH, fixed, 0, SIGNATURE_PART_LENGTH); + } else { + System.arraycopy(bytes, 0, fixed, SIGNATURE_PART_LENGTH - bytes.length, bytes.length); + } + return fixed; + } + + private void copyPart(byte[] source, byte[] target, int offset) { + System.arraycopy(source, 0, target, offset, SIGNATURE_PART_LENGTH); + } + + private byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 digest is unavailable", exception); + } + } + + private byte[] ripemd160(byte[] data) { + RIPEMD160Digest digest = new RIPEMD160Digest(); + digest.update(data, 0, data.length); + byte[] result = new byte[digest.getDigestSize()]; + digest.doFinal(result, 0); + return result; + } + + private byte[] convertBits(byte[] data, int fromBits, int toBits, boolean pad) { + int acc = 0; + int bits = 0; + int maxValue = (1 << toBits) - 1; + byte[] output = new byte[(data.length * fromBits + toBits - 1) / toBits]; + int outputIndex = 0; + for (byte datum : data) { + int value = datum & 0xff; + if ((value >>> fromBits) != 0) { + throw new IllegalArgumentException("Invalid bech32 input value"); + } + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + output[outputIndex] = (byte) ((acc >> bits) & maxValue); + outputIndex++; + } + } + if (pad && bits > 0) { + output[outputIndex] = (byte) ((acc << (toBits - bits)) & maxValue); + outputIndex++; + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxValue) != 0) { + throw new IllegalArgumentException("Invalid bech32 padding"); + } + return Arrays.copyOf(output, outputIndex); + } + + private String encodeBech32(String humanReadablePart, byte[] data) { + byte[] checksum = createChecksum(humanReadablePart, data); + StringBuilder builder = new StringBuilder(humanReadablePart.length() + 1 + data.length + checksum.length); + builder.append(humanReadablePart).append('1'); + appendBech32Data(builder, data); + appendBech32Data(builder, checksum); + return builder.toString(); + } + + private byte[] createChecksum(String humanReadablePart, byte[] data) { + byte[] values = new byte[hrpExpand(humanReadablePart).length + data.length + BECH32_CHECKSUM_LENGTH]; + byte[] expanded = hrpExpand(humanReadablePart); + System.arraycopy(expanded, 0, values, 0, expanded.length); + System.arraycopy(data, 0, values, expanded.length, data.length); + int polymod = polymod(values) ^ 1; + byte[] checksum = new byte[BECH32_CHECKSUM_LENGTH]; + for (int index = 0; index < BECH32_CHECKSUM_LENGTH; index++) { + checksum[index] = (byte) ((polymod >> (5 * (BECH32_CHECKSUM_LENGTH - 1 - index))) & 31); + } + return checksum; + } + + private byte[] hrpExpand(String humanReadablePart) { + byte[] expanded = new byte[humanReadablePart.length() * 2 + 1]; + int index = 0; + for (int position = 0; position < humanReadablePart.length(); position++) { + expanded[index] = (byte) (humanReadablePart.charAt(position) >> 5); + index++; + } + expanded[index] = 0; + index++; + for (int position = 0; position < humanReadablePart.length(); position++) { + expanded[index] = (byte) (humanReadablePart.charAt(position) & 31); + index++; + } + return expanded; + } + + private int polymod(byte[] values) { + int checksum = 1; + for (byte value : values) { + int top = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ (value & 0xff); + checksum = applyGenerator(checksum, top, 0, BECH32_GENERATOR_0); + checksum = applyGenerator(checksum, top, 1, BECH32_GENERATOR_1); + checksum = applyGenerator(checksum, top, 2, BECH32_GENERATOR_2); + checksum = applyGenerator(checksum, top, 3, BECH32_GENERATOR_3); + checksum = applyGenerator(checksum, top, 4, BECH32_GENERATOR_4); + } + return checksum; + } + + private int applyGenerator(int checksum, int top, int bit, int generator) { + return ((top >> bit) & 1) == 1 ? checksum ^ generator : checksum; + } + + private void appendBech32Data(StringBuilder builder, byte[] data) { + for (byte value : data) { + builder.append(BECH32_CHARSET[value]); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + public record SignedRequest(String authorization, String requesterAddress, String timestamp) { + } +} diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaSigningHttpClientBuilder.java b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaSigningHttpClientBuilder.java new file mode 100644 index 000000000..67ac43f02 --- /dev/null +++ b/src/main/java/me/golemcore/bot/adapter/outbound/gonka/GonkaSigningHttpClientBuilder.java @@ -0,0 +1,121 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import dev.langchain4j.http.client.HttpClient; +import dev.langchain4j.http.client.HttpClientBuilder; +import dev.langchain4j.http.client.HttpRequest; +import dev.langchain4j.http.client.SuccessfulHttpResponse; +import dev.langchain4j.http.client.sse.ServerSentEventListener; +import dev.langchain4j.http.client.sse.ServerSentEventParser; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class GonkaSigningHttpClientBuilder implements HttpClientBuilder { + + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_REQUESTER_ADDRESS = "X-Requester-Address"; + private static final String HEADER_TIMESTAMP = "X-Timestamp"; + + private final HttpClientBuilder delegate; + private final GonkaRequestSigner signer; + private final String privateKey; + private final String requesterAddress; + private final String transferAddress; + + public GonkaSigningHttpClientBuilder(HttpClientBuilder delegate, GonkaRequestSigner signer, + String privateKey, String requesterAddress, String transferAddress) { + this.delegate = delegate; + this.signer = signer; + this.privateKey = privateKey; + this.requesterAddress = requesterAddress; + this.transferAddress = transferAddress; + } + + @Override + public Duration connectTimeout() { + return delegate.connectTimeout(); + } + + @Override + public HttpClientBuilder connectTimeout(Duration timeout) { + delegate.connectTimeout(timeout); + return this; + } + + @Override + public Duration readTimeout() { + return delegate.readTimeout(); + } + + @Override + public HttpClientBuilder readTimeout(Duration timeout) { + delegate.readTimeout(timeout); + return this; + } + + @Override + public HttpClient build() { + return new GonkaSigningHttpClient(delegate.build(), signer, privateKey, requesterAddress, transferAddress); + } + + static final class GonkaSigningHttpClient implements HttpClient { + + private final HttpClient delegate; + private final GonkaRequestSigner signer; + private final String privateKey; + private final String requesterAddress; + private final String transferAddress; + + private GonkaSigningHttpClient(HttpClient delegate, GonkaRequestSigner signer, + String privateKey, String requesterAddress, String transferAddress) { + this.delegate = delegate; + this.signer = signer; + this.privateKey = privateKey; + this.requesterAddress = requesterAddress; + this.transferAddress = transferAddress; + } + + @Override + public SuccessfulHttpResponse execute(HttpRequest request) { + return delegate.execute(sign(request)); + } + + @Override + public void execute(HttpRequest request, ServerSentEventParser parser, ServerSentEventListener listener) { + delegate.execute(sign(request), parser, listener); + } + + private HttpRequest sign(HttpRequest request) { + GonkaRequestSigner.SignedRequest signedRequest = signer.sign( + request.body(), + privateKey, + requesterAddress, + transferAddress); + return HttpRequest.builder() + .method(request.method()) + .url(request.url()) + .headers(withGonkaHeaders(request.headers(), signedRequest)) + .formDataFields(request.formDataFields()) + .formDataFiles(request.formDataFiles()) + .body(request.body()) + .build(); + } + + private Map> withGonkaHeaders(Map> originalHeaders, + GonkaRequestSigner.SignedRequest signedRequest) { + Map> headers = new LinkedHashMap<>(); + if (originalHeaders != null) { + for (Map.Entry> entry : originalHeaders.entrySet()) { + headers.put(entry.getKey(), + entry.getValue() != null ? new ArrayList<>(entry.getValue()) : List.of()); + } + } + headers.put(HEADER_AUTHORIZATION, List.of(signedRequest.authorization())); + headers.put(HEADER_REQUESTER_ADDRESS, List.of(signedRequest.requesterAddress())); + headers.put(HEADER_TIMESTAMP, List.of(signedRequest.timestamp())); + return headers; + } + } +} diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapter.java b/src/main/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapter.java index b523cc9c9..b4553a04e 100644 --- a/src/main/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapter.java +++ b/src/main/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapter.java @@ -18,6 +18,7 @@ package me.golemcore.bot.adapter.outbound.llm; +import me.golemcore.bot.adapter.outbound.gonka.GonkaHttpClientBuilderFactory; import me.golemcore.bot.domain.component.LlmComponent; import me.golemcore.bot.domain.model.LlmChunk; import me.golemcore.bot.domain.model.LlmProviderMetadataKeys; @@ -28,6 +29,7 @@ import me.golemcore.bot.domain.model.catalog.ModelCatalogEntry; import me.golemcore.bot.domain.model.RuntimeConfig; import me.golemcore.bot.domain.model.Secret; +import me.golemcore.bot.domain.model.ToolArtifactDownload; import me.golemcore.bot.domain.model.ToolDefinition; import me.golemcore.bot.domain.service.ToolArtifactService; import me.golemcore.bot.domain.service.RuntimeConfigService; @@ -84,7 +86,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.time.Duration; /** * LLM adapter using the langchain4j library. @@ -132,6 +133,7 @@ public class Langchain4jAdapter implements LlmProviderAdapter, LlmComponent { private static final String API_TYPE_OPENAI = "openai"; private static final String API_TYPE_ANTHROPIC = "anthropic"; private static final String API_TYPE_GEMINI = "gemini"; + private static final String API_TYPE_GONKA = "gonka"; private static final String GEMINI_THINKING_SIGNATURE_KEY = "thinking_signature"; private static final String TOOL_ATTACHMENTS_METADATA_KEY = "toolAttachments"; private static final String SYNTH_ID_PREFIX = "synth_call_"; @@ -146,6 +148,7 @@ private record MessageConversionResult(List messages, boolean hydra private final RuntimeConfigService runtimeConfigService; private final ModelConfigPort modelConfig; private final ToolArtifactService toolArtifactService; + private final GonkaHttpClientBuilderFactory gonkaHttpClientBuilderFactory; private final ObjectMapper objectMapper = new ObjectMapper(); private ChatModel chatModel; @@ -233,8 +236,7 @@ private StreamingChatModel createResponsesStreamingModel(String fullModel, Strin if (apiKey.isBlank()) { throw new IllegalStateException("Missing apiKey for OpenAI Responses provider in runtime config"); } - Duration timeout = Duration.ofSeconds( - config.getRequestTimeoutSeconds() != null ? config.getRequestTimeoutSeconds() : 300); + Duration timeout = resolveProviderTimeout(config); OpenAiResponsesStreamingChatModel.Builder builder = OpenAiResponsesStreamingChatModel.builder() .apiKey(apiKey) .modelName(modelName) @@ -300,6 +302,8 @@ private ChatModel createModel(String model, String reasoningEffort) { return createAnthropicModel(model, modelName, config); case API_TYPE_GEMINI: return createGeminiModel(model, modelName, config); + case API_TYPE_GONKA: + return createGonkaModel(modelName, model, config); default: return createOpenAiModel(modelName, model, reasoningEffort, config); } @@ -323,8 +327,7 @@ private ChatModel createAnthropicModel(String fullModel, String modelName, Runti .modelName(modelName) .maxRetries(0) // Retry handled by our backoff logic .maxTokens(4096) - .timeout(java.time.Duration.ofSeconds( - config.getRequestTimeoutSeconds() != null ? config.getRequestTimeoutSeconds() : 300)); + .timeout(resolveProviderTimeout(config)); if (config.getBaseUrl() != null) { builder.baseUrl(config.getBaseUrl()); @@ -350,8 +353,7 @@ private ChatModel createGeminiModel(String fullModel, String modelName, RuntimeC // both returnThinking and sendThinking are enabled. .returnThinking(true) .sendThinking(true) - .timeout(java.time.Duration.ofSeconds( - config.getRequestTimeoutSeconds() != null ? config.getRequestTimeoutSeconds() : 300)); + .timeout(resolveProviderTimeout(config)); if (supportsTemperature(fullModel)) { builder.temperature(runtimeConfigService.getTemperature()); @@ -370,8 +372,7 @@ private ChatModel createOpenAiModel(String modelName, String fullModel, .apiKey(apiKey) .modelName(modelName) .maxRetries(0) // Retry handled by our backoff logic - .timeout(java.time.Duration.ofSeconds( - config.getRequestTimeoutSeconds() != null ? config.getRequestTimeoutSeconds() : 300)); + .timeout(resolveProviderTimeout(config)); if (config.getBaseUrl() != null) { builder.baseUrl(config.getBaseUrl()); @@ -391,6 +392,32 @@ private ChatModel createOpenAiModel(String modelName, String fullModel, return builder.build(); } + private ChatModel createGonkaModel(String modelName, String fullModel, RuntimeConfig.LlmProviderConfig config) { + if (gonkaHttpClientBuilderFactory == null) { + throw new IllegalStateException("Gonka support is not configured"); + } + Duration timeout = resolveProviderTimeout(config); + GonkaHttpClientBuilderFactory.ResolvedGonkaHttpClientBuilder gonkaBuilder = gonkaHttpClientBuilderFactory + .create(config, timeout); + OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder() + .apiKey("mock-api-key") + .modelName(modelName) + .maxRetries(0) + .baseUrl(gonkaBuilder.baseUrl()) + .httpClientBuilder(gonkaBuilder.httpClientBuilder()) + .timeout(timeout); + + if (supportsTemperature(fullModel)) { + builder.temperature(runtimeConfigService.getTemperature()); + } + + return builder.build(); + } + + private Duration resolveProviderTimeout(RuntimeConfig.LlmProviderConfig config) { + return Duration.ofSeconds(config.getRequestTimeoutSeconds() != null ? config.getRequestTimeoutSeconds() : 300); + } + @Override public String getProviderId() { return "langchain4j"; @@ -1059,7 +1086,7 @@ private UserMessage toToolAttachmentContextMessage(Message msg, boolean hydrateI } try { - var download = toolArtifactService.getDownload(internalFilePath); + ToolArtifactDownload download = toolArtifactService.getDownload(internalFilePath); String mimeType = download.getMimeType(); if (mimeType == null || !mimeType.startsWith("image/")) { continue; diff --git a/src/main/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapter.java b/src/main/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapter.java index f6b1ff954..6b65ea61f 100644 --- a/src/main/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapter.java +++ b/src/main/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapter.java @@ -53,7 +53,7 @@ protected HttpRequest buildHttpRequest(DiscoveryRequest request) { builder.header("anthropic-version", "2023-06-01"); } else if (request.authMode() == AuthMode.GOOGLE) { builder.header("x-goog-api-key", request.apiKey()); - } else { + } else if (request.authMode() == AuthMode.BEARER) { builder.header("Authorization", "Bearer " + request.apiKey()); } return builder.build(); diff --git a/src/main/java/me/golemcore/bot/application/models/ProviderModelDiscoveryService.java b/src/main/java/me/golemcore/bot/application/models/ProviderModelDiscoveryService.java index d07ca4bf7..e850dec2a 100644 --- a/src/main/java/me/golemcore/bot/application/models/ProviderModelDiscoveryService.java +++ b/src/main/java/me/golemcore/bot/application/models/ProviderModelDiscoveryService.java @@ -24,6 +24,7 @@ public class ProviderModelDiscoveryService { private static final String API_TYPE_OPENAI = "openai"; private static final String API_TYPE_ANTHROPIC = "anthropic"; private static final String API_TYPE_GEMINI = "gemini"; + private static final String API_TYPE_GONKA = "gonka"; private static final String DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; private static final String DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; private static final String DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; @@ -84,6 +85,9 @@ protected ProviderModelDiscoveryPort.AuthMode resolveAuthMode(String apiType) { if (API_TYPE_GEMINI.equals(apiType)) { return ProviderModelDiscoveryPort.AuthMode.GOOGLE; } + if (API_TYPE_GONKA.equals(apiType)) { + return ProviderModelDiscoveryPort.AuthMode.NONE; + } return ProviderModelDiscoveryPort.AuthMode.BEARER; } @@ -134,6 +138,9 @@ private String defaultBaseUrl(String apiType) { if (API_TYPE_GEMINI.equals(apiType)) { return DEFAULT_GEMINI_BASE_URL; } + if (API_TYPE_GONKA.equals(apiType)) { + throw new IllegalArgumentException("Gonka model discovery requires an explicit provider baseUrl endpoint"); + } return DEFAULT_OPENAI_BASE_URL; } diff --git a/src/main/java/me/golemcore/bot/application/settings/RuntimeSettingsValidator.java b/src/main/java/me/golemcore/bot/application/settings/RuntimeSettingsValidator.java index 58c6882db..2219b8181 100644 --- a/src/main/java/me/golemcore/bot/application/settings/RuntimeSettingsValidator.java +++ b/src/main/java/me/golemcore/bot/application/settings/RuntimeSettingsValidator.java @@ -18,7 +18,6 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.function.Supplier; import java.util.regex.Pattern; public class RuntimeSettingsValidator { @@ -28,7 +27,7 @@ public class RuntimeSettingsValidator { private static final String STT_PROVIDER_WHISPER = "golemcore/whisper"; private static final String LEGACY_STT_PROVIDER_ELEVENLABS = "elevenlabs"; private static final String LEGACY_STT_PROVIDER_WHISPER = "whisper"; - private static final Set VALID_API_TYPES = Set.of("openai", "anthropic", "gemini"); + private static final Set VALID_API_TYPES = Set.of("openai", "anthropic", "gemini", "gonka"); private static final String DEFAULT_COMPACTION_TRIGGER_MODE = "model_ratio"; private static final String COMPACTION_TRIGGER_MODE_TOKEN_THRESHOLD = "token_threshold"; private static final Set VALID_COMPACTION_TRIGGER_MODES = Set.of( @@ -177,10 +176,46 @@ public void validateProviderConfig(String name, RuntimeConfig.LlmProviderConfig "llm.providers." + name + ".baseUrl must be a valid http(s) URL"); } String apiType = config.getApiType(); - if (apiType != null && !apiType.isBlank() && !VALID_API_TYPES.contains(apiType.toLowerCase(Locale.ROOT))) { + String normalizedApiType = apiType != null ? apiType.trim().toLowerCase(Locale.ROOT) : ""; + if (!normalizedApiType.isBlank() && !VALID_API_TYPES.contains(normalizedApiType)) { throw new IllegalArgumentException( "llm.providers." + name + ".apiType must be one of " + VALID_API_TYPES); } + validateGonkaProviderConfig(name, config, normalizedApiType); + } + + private void validateGonkaProviderConfig(String name, RuntimeConfig.LlmProviderConfig config, + String normalizedApiType) { + if (!"gonka".equals(normalizedApiType)) { + return; + } + boolean hasSourceUrl = config.getSourceUrl() != null && !config.getSourceUrl().isBlank(); + boolean hasEndpoints = config.getEndpoints() != null && !config.getEndpoints().isEmpty(); + if (!hasSourceUrl && !hasEndpoints) { + throw new IllegalArgumentException( + "llm.providers." + name + ".sourceUrl or endpoints is required for gonka apiType"); + } + if (hasSourceUrl && !isValidHttpUrl(config.getSourceUrl())) { + throw new IllegalArgumentException( + "llm.providers." + name + ".sourceUrl must be a valid http(s) URL"); + } + if (hasEndpoints) { + validateGonkaEndpoints(name, config.getEndpoints()); + } + } + + private void validateGonkaEndpoints(String name, List endpoints) { + for (RuntimeConfig.GonkaEndpointConfig endpoint : endpoints) { + if (endpoint == null || endpoint.getUrl() == null || endpoint.getUrl().isBlank() + || !isValidHttpUrl(endpoint.getUrl())) { + throw new IllegalArgumentException( + "llm.providers." + name + ".endpoints.url must be a valid http(s) URL"); + } + if (endpoint.getTransferAddress() == null || endpoint.getTransferAddress().isBlank()) { + throw new IllegalArgumentException( + "llm.providers." + name + ".endpoints.transferAddress is required"); + } + } } public String normalizeProviderName(String name) { diff --git a/src/main/java/me/golemcore/bot/domain/model/RuntimeConfig.java b/src/main/java/me/golemcore/bot/domain/model/RuntimeConfig.java index beaf32d0f..fac1c9ec7 100644 --- a/src/main/java/me/golemcore/bot/domain/model/RuntimeConfig.java +++ b/src/main/java/me/golemcore/bot/domain/model/RuntimeConfig.java @@ -13,6 +13,7 @@ import lombok.NoArgsConstructor; import lombok.ToString; +import java.net.URI; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -525,6 +526,10 @@ public static class LlmProviderConfig { private String baseUrl; private Integer requestTimeoutSeconds; private String apiType; + private String sourceUrl; + private String gonkaAddress; + @Builder.Default + private List endpoints = new ArrayList<>(); /** * When {@code true}, forces the legacy {@code /v1/chat/completions} endpoint * for OpenAI-type providers. When {@code null} or {@code false}, the adapter @@ -532,6 +537,23 @@ public static class LlmProviderConfig { * {@code apiType} is {@code "openai"}. */ private Boolean legacyApi; + + @JsonIgnore + public URI getSourceUri() { + if (sourceUrl == null || sourceUrl.isBlank()) { + return null; + } + return URI.create(sourceUrl.trim()); + } + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class GonkaEndpointConfig { + private String url; + private String transferAddress; } @Data diff --git a/src/main/java/me/golemcore/bot/port/outbound/ProviderModelDiscoveryPort.java b/src/main/java/me/golemcore/bot/port/outbound/ProviderModelDiscoveryPort.java index 6f3e8358b..368682b2d 100644 --- a/src/main/java/me/golemcore/bot/port/outbound/ProviderModelDiscoveryPort.java +++ b/src/main/java/me/golemcore/bot/port/outbound/ProviderModelDiscoveryPort.java @@ -9,7 +9,7 @@ public interface ProviderModelDiscoveryPort { DiscoveryResponse discover(DiscoveryRequest request); enum AuthMode { - BEARER, ANTHROPIC, GOOGLE + BEARER, ANTHROPIC, GOOGLE, NONE } enum DocumentKind { diff --git a/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolutionAdapterTest.java b/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolutionAdapterTest.java new file mode 100644 index 000000000..3b29e374c --- /dev/null +++ b/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaEndpointResolutionAdapterTest.java @@ -0,0 +1,190 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Executor; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.Test; + +class GonkaEndpointResolutionAdapterTest { + + @Test + void shouldUseExplicitConfiguredEndpointBeforeSourceDiscovery() { + GonkaEndpointResolutionAdapter adapter = new GonkaEndpointResolutionAdapter(); + + GonkaEndpointResolver.GonkaResolvedEndpoint endpoint = adapter.resolve( + new GonkaEndpointResolver.GonkaEndpointResolutionRequest( + URI.create("https://node3.gonka.ai"), + List.of(new GonkaEndpointResolver.GonkaConfiguredEndpoint( + "https://host.example", "gonka1provider")), + Duration.ofSeconds(5))); + + assertEquals("https://host.example/v1", endpoint.url()); + assertEquals("gonka1provider", endpoint.transferAddress()); + } + + @Test + void shouldResolveEndpointFromParticipantsAndAllowedTransferAgents() { + FakeHttpClient httpClient = new FakeHttpClient(Map.of( + "https://node3.gonka.ai/chain-api/productscience/inference/inference/params", + """ + {"params":{"transfer_agent_access_params":{"allowed_transfer_addresses":["gonka1allowed"]}}} + """, + "https://node3.gonka.ai/v1/epochs/current/participants", + """ + {"excluded_participants":[],"active_participants":{"participants":[ + {"index":"gonka1blocked","inference_url":"https://blocked.example"}, + {"index":"gonka1allowed","inference_url":"https://allowed.example"} + ]}} + """, + "https://allowed.example/v1/identity", + """ + {"data":{"delegate_ta":{"https://delegate.example":"gonka1delegate"}}} + """)); + TestGonkaEndpointResolutionAdapter adapter = new TestGonkaEndpointResolutionAdapter(httpClient); + + GonkaEndpointResolver.GonkaResolvedEndpoint endpoint = adapter.resolve( + new GonkaEndpointResolver.GonkaEndpointResolutionRequest( + URI.create("https://node3.gonka.ai"), + List.of(), + Duration.ofSeconds(5))); + + assertEquals("https://delegate.example/v1", endpoint.url()); + assertEquals("gonka1allowed", endpoint.transferAddress()); + } + + private static final class TestGonkaEndpointResolutionAdapter extends GonkaEndpointResolutionAdapter { + + private final HttpClient httpClient; + + private TestGonkaEndpointResolutionAdapter(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + protected HttpClient buildHttpClient(Duration timeout) { + return httpClient; + } + } + + private static final class FakeHttpClient extends HttpClient { + + private final Map responses; + + private FakeHttpClient(Map responses) { + this.responses = responses; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NORMAL; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return null; + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + String body = responses.get(request.uri().toString()); + @SuppressWarnings("unchecked") + HttpResponse response = (HttpResponse) new FakeHttpResponse(request, body != null ? 200 : 404, + body != null ? body : "{}"); + return response; + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + throw new UnsupportedOperationException(); + } + } + + private record FakeHttpResponse(HttpRequest request, int statusCode, String body) implements HttpResponse { + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (name, value) -> true); + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } +}} diff --git a/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaRequestSignerTest.java b/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaRequestSignerTest.java new file mode 100644 index 000000000..c2a721ae8 --- /dev/null +++ b/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaRequestSignerTest.java @@ -0,0 +1,64 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class GonkaRequestSignerTest { + + private static final String PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000001"; + + @Test + void shouldSignPayloadAndDeriveRequesterAddressWhenAddressMissing() { + Clock clock = Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC); + GonkaRequestSigner signer = new GonkaRequestSigner(clock); + + GonkaRequestSigner.SignedRequest signed = signer.sign( + "{\"model\":\"qwen\"}", + PRIVATE_KEY, + null, + "gonka1provideraddress"); + + assertEquals("1700000000000000000", signed.timestamp()); + assertEquals("gonka1w508d6qejxtdg4y5r3zarvary0c5xw7k2gsyg6", signed.requesterAddress()); + assertNotNull(signed.authorization()); + assertEquals(64, Base64.getDecoder().decode(signed.authorization()).length); + assertFalse(signed.authorization().startsWith("Bearer ")); + } + + @Test + void shouldIncreaseTimestampWhenClockDoesNotMove() { + Clock clock = Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC); + GonkaRequestSigner signer = new GonkaRequestSigner(clock); + + GonkaRequestSigner.SignedRequest first = signer.sign("{}", PRIVATE_KEY, "gonka1requester", + "gonka1provideraddress"); + GonkaRequestSigner.SignedRequest second = signer.sign("{}", PRIVATE_KEY, "gonka1requester", + "gonka1provideraddress"); + + assertEquals("1700000000000000000", first.timestamp()); + assertEquals("1700000000000000001", second.timestamp()); + } + + @Test + void shouldUseExplicitRequesterAddress() { + Clock clock = Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC); + GonkaRequestSigner signer = new GonkaRequestSigner(clock); + + GonkaRequestSigner.SignedRequest signed = signer.sign( + "{}", + PRIVATE_KEY, + "gonka1requester", + "gonka1provideraddress"); + + assertEquals("gonka1requester", signed.requesterAddress()); + assertTrue(signed.authorization().length() > 0); + } +} diff --git a/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaSigningHttpClientBuilderTest.java b/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaSigningHttpClientBuilderTest.java new file mode 100644 index 000000000..d1022e64b --- /dev/null +++ b/src/test/java/me/golemcore/bot/adapter/outbound/gonka/GonkaSigningHttpClientBuilderTest.java @@ -0,0 +1,104 @@ +package me.golemcore.bot.adapter.outbound.gonka; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.langchain4j.http.client.HttpClient; +import dev.langchain4j.http.client.HttpClientBuilder; +import dev.langchain4j.http.client.HttpMethod; +import dev.langchain4j.http.client.HttpRequest; +import dev.langchain4j.http.client.SuccessfulHttpResponse; +import dev.langchain4j.http.client.sse.ServerSentEventListener; +import dev.langchain4j.http.client.sse.ServerSentEventParser; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.Test; + +class GonkaSigningHttpClientBuilderTest { + + private static final String PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000001"; + + @Test + void shouldReplaceBearerHeaderWithGonkaSignatureHeaders() { + CapturingHttpClient delegateClient = new CapturingHttpClient(); + HttpClientBuilder delegateBuilder = new StubHttpClientBuilder(delegateClient); + GonkaRequestSigner signer = new GonkaRequestSigner( + Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC)); + GonkaSigningHttpClientBuilder builder = new GonkaSigningHttpClientBuilder( + delegateBuilder, signer, PRIVATE_KEY, "gonka1requester", "gonka1provideraddress"); + + HttpRequest request = HttpRequest.builder() + .method(HttpMethod.POST) + .url("https://node3.gonka.ai/v1/chat/completions") + .addHeader("Authorization", "Bearer mock-api-key") + .addHeader("Content-Type", "application/json") + .body("{\"messages\":[]}") + .build(); + + builder.build().execute(request); + + assertEquals("gonka1requester", + delegateClient.lastRequest.headers().get("X-Requester-Address").getFirst()); + assertEquals("1700000000000000000", + delegateClient.lastRequest.headers().get("X-Timestamp").getFirst()); + String authorization = delegateClient.lastRequest.headers().get("Authorization").getFirst(); + assertEquals(false, authorization.startsWith("Bearer ")); + assertEquals(List.of("application/json"), delegateClient.lastRequest.headers().get("Content-Type")); + } + + private static final class StubHttpClientBuilder implements HttpClientBuilder { + + private final HttpClient httpClient; + private Duration connectTimeout; + private Duration readTimeout; + + private StubHttpClientBuilder(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public Duration connectTimeout() { + return connectTimeout; + } + + @Override + public HttpClientBuilder connectTimeout(Duration timeout) { + connectTimeout = timeout; + return this; + } + + @Override + public Duration readTimeout() { + return readTimeout; + } + + @Override + public HttpClientBuilder readTimeout(Duration timeout) { + readTimeout = timeout; + return this; + } + + @Override + public HttpClient build() { + return httpClient; + } + } + + private static final class CapturingHttpClient implements HttpClient { + + private HttpRequest lastRequest; + + @Override + public SuccessfulHttpResponse execute(HttpRequest request) { + lastRequest = request; + return SuccessfulHttpResponse.builder().statusCode(200).body("{}").build(); + } + + @Override + public void execute(HttpRequest request, ServerSentEventParser parser, ServerSentEventListener listener) { + lastRequest = request; + } + } +} diff --git a/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterRetryTest.java b/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterRetryTest.java index 321c6a8f4..d8d5f21ba 100644 --- a/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterRetryTest.java +++ b/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterRetryTest.java @@ -76,6 +76,6 @@ void isRateLimitError_returnsFalseForNullMessage() { private Langchain4jAdapter createMinimalAdapter() { // Create with nulls -- only testing isRateLimitError/sanitize which don't use // fields - return new Langchain4jAdapter(null, null, null); + return new Langchain4jAdapter(null, null, null, null); } } diff --git a/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterTest.java b/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterTest.java index fd4e34100..64dcc51f4 100644 --- a/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterTest.java +++ b/src/test/java/me/golemcore/bot/adapter/outbound/llm/Langchain4jAdapterTest.java @@ -29,6 +29,9 @@ import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.openai.OpenAiChatModel; +import me.golemcore.bot.adapter.outbound.gonka.GonkaEndpointResolver; +import me.golemcore.bot.adapter.outbound.gonka.GonkaHttpClientBuilderFactory; +import me.golemcore.bot.adapter.outbound.gonka.GonkaRequestSigner; import dev.langchain4j.model.output.TokenUsage; import dev.langchain4j.agent.tool.ToolExecutionRequest; import org.mockito.ArgumentCaptor; @@ -88,6 +91,7 @@ class Langchain4jAdapterTest { private ModelConfigPort modelConfig; private RuntimeConfigService runtimeConfigService; private ToolArtifactService toolArtifactService; + private GonkaEndpointResolver gonkaEndpointResolutionPort; private Langchain4jAdapter adapter; @BeforeEach @@ -95,6 +99,10 @@ void setUp() { modelConfig = mock(ModelConfigPort.class); runtimeConfigService = mock(RuntimeConfigService.class); toolArtifactService = mock(ToolArtifactService.class); + gonkaEndpointResolutionPort = mock(GonkaEndpointResolver.class); + when(gonkaEndpointResolutionPort.resolve(any())) + .thenReturn(new GonkaEndpointResolver.GonkaResolvedEndpoint( + "https://node3.gonka.ai/v1", "gonka1provideraddress")); when(modelConfig.supportsTemperature(anyString())).thenReturn(true); when(modelConfig.supportsVision(anyString())).thenReturn(false); when(modelConfig.getProvider(anyString())).thenReturn(OPENAI); @@ -108,7 +116,8 @@ void setUp() { when(runtimeConfigService.getLlmProviderConfig(anyString())) .thenReturn(RuntimeConfig.LlmProviderConfig.builder().legacyApi(true).build()); - adapter = new Langchain4jAdapter(runtimeConfigService, modelConfig, toolArtifactService) { + adapter = new Langchain4jAdapter(runtimeConfigService, modelConfig, toolArtifactService, + new GonkaHttpClientBuilderFactory(gonkaEndpointResolutionPort, new GonkaRequestSigner())) { @Override protected void sleepBeforeRetry(long backoffMs) { // No-op for deterministic fast retry tests. @@ -1929,6 +1938,23 @@ void shouldReuseCurrentModelWhenRequestMatchesConfiguredModel() { assertSame(defaultModel, result); } + @Test + void shouldCreateOpenAiCompatibleModelForGonkaApiType() { + String requestModel = "gonka/Qwen/Qwen3-235B-A22B-Instruct-2507-FP8"; + when(modelConfig.getProvider(requestModel)).thenReturn("gonka"); + when(runtimeConfigService.getLlmProviderConfig("gonka")) + .thenReturn(RuntimeConfig.LlmProviderConfig.builder() + .apiKey(Secret.of("0000000000000000000000000000000000000000000000000000000000000001")) + .apiType("gonka") + .sourceUrl("https://node3.gonka.ai") + .build()); + + ChatModel result = ReflectionTestUtils.invokeMethod(adapter, "createModel", requestModel, null); + + assertTrue(result instanceof OpenAiChatModel); + verify(gonkaEndpointResolutionPort).resolve(any()); + } + @Test void shouldFallbackToOpenAiWhenApiTypeIsUnknown() { String requestModel = "custom-provider/gpt-5.1"; diff --git a/src/test/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapterTest.java b/src/test/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapterTest.java index 5a4a740af..1c1b0e2ed 100644 --- a/src/test/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapterTest.java +++ b/src/test/java/me/golemcore/bot/adapter/outbound/models/HttpProviderModelDiscoveryAdapterTest.java @@ -64,6 +64,21 @@ void shouldUseAnthropicHeaders() { httpClient.getCapturedRequest().headers().firstValue("anthropic-version").orElse("")); } + @Test + void shouldOmitAuthorizationHeaderForNoneAuthMode() { + FakeHttpClient httpClient = new FakeHttpClient(FakeHttpClient.Mode.SUCCESS, 200, "{\"data\":[]}"); + TestHttpProviderModelDiscoveryAdapter adapter = new TestHttpProviderModelDiscoveryAdapter(httpClient); + + adapter.discover(new ProviderModelDiscoveryPort.DiscoveryRequest( + URI.create("https://node3.gonka.ai/v1/models"), + Duration.ofSeconds(20), + "private-key", + "golemcore-model-discovery", + ProviderModelDiscoveryPort.AuthMode.NONE)); + + assertEquals(Optional.empty(), httpClient.getCapturedRequest().headers().firstValue("Authorization")); + } + @Test void shouldUseGoogleHeaderAndParseGeminiPayload() { FakeHttpClient httpClient = new FakeHttpClient(FakeHttpClient.Mode.SUCCESS, 200, diff --git a/src/test/java/me/golemcore/bot/application/settings/RuntimeSettingsValidatorTest.java b/src/test/java/me/golemcore/bot/application/settings/RuntimeSettingsValidatorTest.java index ae11f1e11..3d5670bc3 100644 --- a/src/test/java/me/golemcore/bot/application/settings/RuntimeSettingsValidatorTest.java +++ b/src/test/java/me/golemcore/bot/application/settings/RuntimeSettingsValidatorTest.java @@ -317,4 +317,29 @@ void shouldDefaultMissingProvidersAndCompactionTriggerMode() { assertEquals(Map.of(), llmConfig.getProviders()); assertEquals("model_ratio", compactionConfig.getTriggerMode()); } + + @Test + void shouldAcceptGonkaProviderWhenSourceUrlConfigured() { + RuntimeConfig.LlmProviderConfig config = RuntimeConfig.LlmProviderConfig.builder() + .apiKey(me.golemcore.bot.domain.model.Secret + .of("0000000000000000000000000000000000000000000000000000000000000001")) + .apiType("gonka") + .sourceUrl("https://node3.gonka.ai") + .build(); + + assertDoesNotThrow(() -> validator.validateProviderConfig("gonka", config)); + } + + @Test + void shouldRejectGonkaProviderWithoutSourceUrlOrEndpoints() { + RuntimeConfig.LlmProviderConfig config = RuntimeConfig.LlmProviderConfig.builder() + .apiType("gonka") + .build(); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> validator.validateProviderConfig("gonka", config)); + + assertTrue(error.getMessage().contains("sourceUrl or endpoints is required")); + } + } diff --git a/src/test/java/me/golemcore/bot/domain/service/ProviderModelDiscoveryServiceTest.java b/src/test/java/me/golemcore/bot/domain/service/ProviderModelDiscoveryServiceTest.java index 99656c6b3..faae1ce99 100644 --- a/src/test/java/me/golemcore/bot/domain/service/ProviderModelDiscoveryServiceTest.java +++ b/src/test/java/me/golemcore/bot/domain/service/ProviderModelDiscoveryServiceTest.java @@ -367,6 +367,27 @@ void shouldTreatInvalidOpenRouterBaseUrlAsNonOpenRouterAndUseDefaultMaxTokensFal assertEquals(128000, maxTokens); } + @Test + void shouldUseNoneAuthForGonkaDiscoveryWhenBaseUrlConfigured() { + RuntimeConfigService runtimeConfigService = mock(RuntimeConfigService.class); + RuntimeConfig.LlmProviderConfig providerConfig = RuntimeConfig.LlmProviderConfig.builder() + .apiKey(Secret.of("0000000000000000000000000000000000000000000000000000000000000001")) + .baseUrl("https://node3.gonka.ai/v1") + .apiType("gonka") + .build(); + when(runtimeConfigService.getConfiguredLlmProviders()).thenReturn(List.of("gonka")); + when(runtimeConfigService.getLlmProviderConfig("gonka")).thenReturn(providerConfig); + StubProviderModelDiscoveryPort discoveryPort = new StubProviderModelDiscoveryPort( + new ProviderModelDiscoveryPort.DiscoveryResponse(200, List.of())); + ProviderModelDiscoveryService service = new ProviderModelDiscoveryService(runtimeConfigService, discoveryPort); + + List models = service.discoverModels("gonka"); + + assertTrue(models.isEmpty()); + assertEquals("https://node3.gonka.ai/v1/models", discoveryPort.capturedRequest().uri().toString()); + assertEquals(ProviderModelDiscoveryPort.AuthMode.NONE, discoveryPort.capturedRequest().authMode()); + } + private static ProviderModelDiscoveryPort.DiscoveryDocument openAiDocument( String id, String displayName,