diff --git a/.github/workflows/e2e-standalone.yml b/.github/workflows/e2e-standalone.yml index f15a6c045..dacf7f46e 100644 --- a/.github/workflows/e2e-standalone.yml +++ b/.github/workflows/e2e-standalone.yml @@ -72,11 +72,22 @@ jobs: done' echo "Neo4j is healthy!" + - name: Fetch Rust dependencies (with retry) + run: | + for i in 1 2 3; do + cargo fetch && break + echo "cargo fetch attempt $i failed, retrying in 15s..." + sleep 15 + done + + - name: Build Rust Standalone Server + run: cargo build --bin standalone --features neo4j + - name: Start Rust Standalone Server run: | export TEST_REF_ID=1 export USE_LSP=0 - cargo run --bin standalone --features neo4j > standalone.log 2>&1 & + ./target/debug/standalone > standalone.log 2>&1 & echo $! > standalone.pid - name: Wait for Rust server diff --git a/.github/workflows/rust-test-lsp.yml b/.github/workflows/rust-test-lsp.yml index 6131f3720..fa5e40bb8 100644 --- a/.github/workflows/rust-test-lsp.yml +++ b/.github/workflows/rust-test-lsp.yml @@ -47,8 +47,10 @@ jobs: - name: Install TypeScript and TypeScript LSP run: | npm install -g typescript typescript-language-server - export PATH="$(npm bin -g):$PATH" - echo "$(npm bin -g)" >> $GITHUB_PATH + NPM_GLOBAL_BIN="$(npm config get prefix)/bin" + NPM_GLOBAL_ROOT="$(npm root -g)" + echo "$NPM_GLOBAL_BIN" >> $GITHUB_PATH + echo "TSSERVER_PATH=$NPM_GLOBAL_ROOT/typescript/bin/tsserver" >> $GITHUB_ENV - name: Verify TypeScript Installation run: | @@ -141,6 +143,13 @@ jobs: which solargraph || echo "solargraph not found" which ruby-lsp || echo "ruby-lsp not found" + # -------------------- Install TypeScript deps for test fixtures -------------------- + - name: Install npm deps for TypeScript test fixtures + run: | + npm install --prefix ast/src/testing/nextjs + npm install --prefix ast/src/testing/react --legacy-peer-deps + npm install --prefix ast/src/testing/typescript + # -------------------- Rust full test with LSP -------------------- - name: Run rust library tests with LSP run: USE_LSP=true cargo test -p ast --lib -- --test-threads=1 diff --git a/lsp/src/client.rs b/lsp/src/client.rs index 9433f2db9..16cc68879 100644 --- a/lsp/src/client.rs +++ b/lsp/src/client.rs @@ -105,13 +105,15 @@ impl LspClient { } pub async fn init(&mut self) -> Result { debug!("LSP init... {:?}", self.root); + let root_uri = Url::from_file_path(&self.root).map_err(|_| { + Error::validation(format!("Invalid root path for LSP: {:?}", self.root)) + })?; let ret = self .server .initialize(InitializeParams { + root_uri: Some(root_uri.clone()), workspace_folders: Some(vec![WorkspaceFolder { - uri: Url::from_file_path(&self.root).map_err(|_| { - Error::validation(format!("Invalid root path for LSP: {:?}", self.root)) - })?, + uri: root_uri, name: "root".into(), }]), capabilities: ClientCapabilities { diff --git a/lsp/src/language.rs b/lsp/src/language.rs index c5d33be57..826d1baa3 100644 --- a/lsp/src/language.rs +++ b/lsp/src/language.rs @@ -226,7 +226,16 @@ impl Language { match self { Self::Rust => Vec::new(), Self::Go => Vec::new(), - Self::Typescript => vec!["--stdio".to_string()], + Self::Typescript => { + let mut args = vec!["--stdio".to_string()]; + if let Ok(tsserver_path) = std::env::var("TSSERVER_PATH") { + if !tsserver_path.is_empty() { + args.push("--tsserver-path".to_string()); + args.push(tsserver_path); + } + } + args + } Self::Python => Vec::new(), Self::Ruby => Vec::new(), Self::Kotlin => Vec::new(), diff --git a/mcp/src/aieo/src/__tests__/resolveRequestUrl.test.ts b/mcp/src/aieo/src/__tests__/resolveRequestUrl.test.ts new file mode 100644 index 000000000..14c01af46 --- /dev/null +++ b/mcp/src/aieo/src/__tests__/resolveRequestUrl.test.ts @@ -0,0 +1,82 @@ +import { test, expect } from "@playwright/test"; +import { resolveRequestUrl } from "../provider.js"; + +// --------------------------------------------------------------------------- +// resolveRequestUrl — unit tests +// +// Verifies the fully-qualified HTTP endpoint returned per provider, +// both with an explicit baseUrl (gateway) and without (direct / no env). +// --------------------------------------------------------------------------- + +test.describe("resolveRequestUrl — with explicit baseUrl (gateway)", () => { + test("anthropic → /messages", () => { + expect(resolveRequestUrl("anthropic", "http://gw:3000")).toBe( + "http://gw:3000/anthropic/v1/messages", + ); + }); + + test("openai → /chat/completions", () => { + expect(resolveRequestUrl("openai", "http://gw:3000")).toBe( + "http://gw:3000/openai/v1/chat/completions", + ); + }); + + test("openrouter → /chat/completions (rides openai path)", () => { + expect(resolveRequestUrl("openrouter", "http://gw:3000")).toBe( + "http://gw:3000/openai/v1/chat/completions", + ); + }); + + test("google → /openai/v1/chat/completions (compat path)", () => { + expect(resolveRequestUrl("google", "http://gw:3000")).toBe( + "http://gw:3000/openai/v1/chat/completions", + ); + }); + + test("google strips provider suffix to recover gateway root", () => { + // gatewayUrlFor("google", "http://gw:3000") → "http://gw:3000/genai/v1beta" + // resolveRequestUrl must strip /genai/v1beta and re-target /openai/v1/chat/completions + const result = resolveRequestUrl("google", "http://gw:3000"); + expect(result).toBe("http://gw:3000/openai/v1/chat/completions"); + // Confirm the genai path is NOT present in the final URL + expect(result).not.toContain("/genai/"); + }); +}); + +test.describe("resolveRequestUrl — without baseUrl and no LLM_GATEWAY_URL env", () => { + // These tests assume LLM_GATEWAY_URL is not set in the test environment. + // If it is set, getGatewayBaseURL() will return a value and the test + // would produce a URL instead of undefined — skip gracefully in that case. + + test("anthropic → undefined (no gateway configured)", () => { + if (process.env.LLM_GATEWAY_URL) { + test.skip(true, "LLM_GATEWAY_URL is set; skipping no-gateway test"); + return; + } + expect(resolveRequestUrl("anthropic", undefined)).toBeUndefined(); + }); + + test("openai → undefined (no gateway configured)", () => { + if (process.env.LLM_GATEWAY_URL) { + test.skip(true, "LLM_GATEWAY_URL is set; skipping no-gateway test"); + return; + } + expect(resolveRequestUrl("openai", undefined)).toBeUndefined(); + }); + + test("openrouter → undefined (no gateway configured)", () => { + if (process.env.LLM_GATEWAY_URL) { + test.skip(true, "LLM_GATEWAY_URL is set; skipping no-gateway test"); + return; + } + expect(resolveRequestUrl("openrouter", undefined)).toBeUndefined(); + }); + + test("google → undefined (no gateway configured)", () => { + if (process.env.LLM_GATEWAY_URL) { + test.skip(true, "LLM_GATEWAY_URL is set; skipping no-gateway test"); + return; + } + expect(resolveRequestUrl("google", undefined)).toBeUndefined(); + }); +}); diff --git a/mcp/src/aieo/src/provider.ts b/mcp/src/aieo/src/provider.ts index 555824311..37b5a2295 100644 --- a/mcp/src/aieo/src/provider.ts +++ b/mcp/src/aieo/src/provider.ts @@ -88,6 +88,42 @@ export function gatewayUrlFor(provider: Provider, baseUrl: string): string { return `${trimmed}${providerPath}`; } +/** + * Returns the fully-qualified HTTP endpoint the SDK will hit for `provider`, + * given an optional caller-supplied `baseUrl`. Returns `undefined` when no + * gateway is configured and the SDK would call the provider directly. + * + * Endpoint path per SDK: + * anthropic → /messages + * openai / openrouter → /chat/completions + * google via gateway → /openai/v1/chat/completions (compat path) + * google direct → undefined (no fixed base URL) + */ +export function resolveRequestUrl( + provider: Provider, + baseUrl?: string, +): string | undefined { + const gatewayBase = baseUrl + ? gatewayUrlFor(provider, baseUrl) + : getGatewayBaseURL(provider); + if (!gatewayBase) return undefined; + + if (provider === "anthropic") { + return `${gatewayBase}/messages`; + } + if (provider === "google") { + // Mirror getModel(): strip any provider suffix to recover the gateway + // root, then target the OpenAI-compat endpoint. + const root = gatewayBase.replace( + /\/(anthropic|openai|genai)\/v\d+[a-z]*\/?$/i, + "", + ); + return `${root}/openai/v1/chat/completions`; + } + // openai, openrouter + return `${gatewayBase}/chat/completions`; +} + /** * Like {@link gatewayUrlFor} but takes a model name (shortcut like * `"sonnet"`, namespaced like `"anthropic/claude-sonnet-5"`, or a full diff --git a/mcp/src/repo/agent.ts b/mcp/src/repo/agent.ts index 9ba96a2e0..b1ab79b0a 100644 --- a/mcp/src/repo/agent.ts +++ b/mcp/src/repo/agent.ts @@ -13,6 +13,7 @@ import { ModelName, getModelDetails, getProviderOptions, + resolveRequestUrl, normalizeUsage, } from "../aieo/src/index.js"; import { get_tools, ToolsConfig, SkillsConfig, GgnnConfig, MessagesRef, ProvenanceCollector } from "./tools.js"; @@ -556,6 +557,7 @@ Apply the guidance from each skill throughout your response.`; ), providerConfig: getProviderOptions(provider as any, undefined, modelId), baseUrl: opts.baseUrl, + requestUrl: resolveRequestUrl(provider as any, opts.baseUrl), // Redact secrets before persisting mcpServers: opts.mcpServers?.map(({ token, headers, ...rest }) => rest), subAgents: opts.subAgents?.map(({ apiToken, ...rest }) => rest), diff --git a/mcp/src/repo/session.ts b/mcp/src/repo/session.ts index 1d3e86626..150ded7c7 100644 --- a/mcp/src/repo/session.ts +++ b/mcp/src/repo/session.ts @@ -47,6 +47,7 @@ export interface SessionInitConfig { tools?: Record; // name → description for every resolved tool providerConfig?: { [key: string]: any }; // resolved getProviderOptions output baseUrl?: string; + requestUrl?: string; mcpServers?: { [key: string]: any }[]; // secrets (token/headers) redacted subAgents?: { [key: string]: any }[]; // secrets (apiToken) redacted ggnn?: { [key: string]: any };