diff --git a/client/resource_urls.go b/client/resource_urls.go index 57f3adb5fea..0b7a31cd004 100644 --- a/client/resource_urls.go +++ b/client/resource_urls.go @@ -18,12 +18,13 @@ const ( // ResourceURLOptions carries the optional resource_urls query parameter shared // by chat, message-history, knowledge-search, and hybrid-search endpoints. +// Empty inherits the deployment default; either explicit mode overrides it. type ResourceURLOptions struct { ResourceURLs ResourceURLMode } func applyResourceURLQuery(q url.Values, opts *ResourceURLOptions) { - if opts == nil || opts.ResourceURLs == "" || opts.ResourceURLs == ResourceURLModeHandle { + if opts == nil || opts.ResourceURLs == "" { return } q.Set("resource_urls", string(opts.ResourceURLs)) diff --git a/client/resource_urls_http_test.go b/client/resource_urls_http_test.go new file mode 100644 index 00000000000..76818a0e5d0 --- /dev/null +++ b/client/resource_urls_http_test.go @@ -0,0 +1,168 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestResourceURLModesReachPublicRequests(t *testing.T) { + methods := []struct { + name string + path string + method string + invoke func(context.Context, *Client, []ResourceURLOptions) (string, error) + }{ + { + name: "message history", path: "/api/v1/messages/session-1/load", method: http.MethodGet, + invoke: func(ctx context.Context, c *Client, opts []ResourceURLOptions) (string, error) { + messages, err := c.LoadMessages(ctx, "session-1", 10, nil, opts...) + if err != nil { + return "", err + } + if len(messages) != 1 { + return "", fmt.Errorf("messages=%d, want 1", len(messages)) + } + return messages[0].Content, nil + }, + }, + { + name: "hybrid search", path: "/api/v1/knowledge-bases/kb-1/hybrid-search", method: http.MethodPost, + invoke: func(ctx context.Context, c *Client, opts []ResourceURLOptions) (string, error) { + results, err := c.HybridSearch(ctx, "kb-1", &SearchParams{QueryText: "figure"}, opts...) + if err != nil { + return "", err + } + if len(results) != 1 { + return "", fmt.Errorf("results=%d, want 1", len(results)) + } + return results[0].Content, nil + }, + }, + { + name: "knowledge search", path: "/api/v1/knowledge-search", method: http.MethodPost, + invoke: func(ctx context.Context, c *Client, opts []ResourceURLOptions) (string, error) { + results, err := c.SearchKnowledge(ctx, &SearchKnowledgeRequest{Query: "figure"}, opts...) + if err != nil { + return "", err + } + if len(results) != 1 { + return "", fmt.Errorf("results=%d, want 1", len(results)) + } + return results[0].Content, nil + }, + }, + { + name: "knowledge chat", path: "/api/v1/knowledge-chat/session-1", method: http.MethodPost, + invoke: func(ctx context.Context, c *Client, opts []ResourceURLOptions) (string, error) { + var answer string + err := c.KnowledgeQAStream(ctx, "session-1", &KnowledgeQARequest{Query: "figure"}, func(event *StreamResponse) error { + answer += event.Content + return nil + }, opts...) + return answer, err + }, + }, + { + name: "agent chat", path: "/api/v1/agent-chat/session-1", method: http.MethodPost, + invoke: func(ctx context.Context, c *Client, opts []ResourceURLOptions) (string, error) { + var answer string + err := c.AgentQAStreamWithRequest(ctx, "session-1", &AgentQARequest{Query: "figure"}, func(event *AgentStreamResponse) error { + answer += event.Content + return nil + }, opts...) + return answer, err + }, + }, + { + name: "resume", path: "/api/v1/sessions/continue-stream/session-1", method: http.MethodGet, + invoke: func(ctx context.Context, c *Client, opts []ResourceURLOptions) (string, error) { + var answer string + err := c.ContinueStream(ctx, "session-1", "message-1", func(event *StreamResponse) error { + answer += event.Content + return nil + }, opts...) + return answer, err + }, + }, + } + modes := []struct { + name string + opts []ResourceURLOptions + wire string + }{ + {name: "omitted inherits public"}, + {name: "empty inherits public", opts: []ResourceURLOptions{{}}}, + {name: "explicit public", opts: []ResourceURLOptions{{ResourceURLs: ResourceURLModePublic}}, wire: "public"}, + {name: "explicit handle", opts: []ResourceURLOptions{{ResourceURLs: ResourceURLModeHandle}}, wire: "handle"}, + } + for _, method := range methods { + for _, mode := range modes { + t.Run(method.name+"/"+mode.name, func(t *testing.T) { + received := make(chan url.Values, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method.method || r.URL.Path != method.path { + http.Error(w, "unexpected request route", http.StatusBadRequest) + return + } + query := r.URL.Query() + received <- query + content := "https://cdn.example.invalid/figure.png" + if query.Get("resource_urls") == "handle" { + content = "resource://figure-1" + } + if strings.Contains(method.path, "-chat/") || strings.Contains(method.path, "continue-stream/") { + w.Header().Set("Content-Type", "text/event-stream") + frame, err := json.Marshal(map[string]any{"response_type": "answer", "content": content, "done": false}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, "event: message\ndata: %s\n\nevent: message\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n", frame) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": []map[string]any{{"content": content}}}) + })) + t.Cleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + content, err := method.invoke(ctx, NewClient(server.URL), mode.opts) + if err != nil { + t.Fatal(err) + } + want := "https://cdn.example.invalid/figure.png" + if mode.wire == "handle" { + want = "resource://figure-1" + } + if content != want { + t.Errorf("returned resource=%q, want %q", content, want) + } + var query url.Values + select { + case query = <-received: + case <-ctx.Done(): + t.Fatal("SDK returned without making the expected request") + } + if got := query.Get("resource_urls"); got != mode.wire { + t.Errorf("resource_urls=%q, want %q", got, mode.wire) + } + if mode.wire == "" && query.Has("resource_urls") { + t.Errorf("inherited mode must leave resource_urls absent: %v", query) + } + if method.name == "message history" && query.Get("limit") != "10" { + t.Errorf("message limit changed: %v", query) + } + if method.name == "resume" && query.Get("message_id") != "message-1" { + t.Errorf("resume message id changed: %v", query) + } + }) + } + } +} diff --git a/client/resource_urls_test.go b/client/resource_urls_test.go index f081bd75684..37911333416 100644 --- a/client/resource_urls_test.go +++ b/client/resource_urls_test.go @@ -14,8 +14,8 @@ func TestApplyResourceURLQuery(t *testing.T) { q = url.Values{} applyResourceURLQuery(q, &ResourceURLOptions{ResourceURLs: ResourceURLModeHandle}) - if len(q) != 0 { - t.Fatalf("handle mode: got %v, want empty", q) + if got := q.Get("resource_urls"); got != "handle" { + t.Fatalf("handle mode: got %q, want handle", got) } q = url.Values{} diff --git a/packages/dsh-weknora/src/client.ts b/packages/dsh-weknora/src/client.ts index 1975a972354..c71732639eb 100644 --- a/packages/dsh-weknora/src/client.ts +++ b/packages/dsh-weknora/src/client.ts @@ -138,7 +138,7 @@ export class WeknoraClient { for (const [key, value] of Object.entries(query ?? {})) { if (value !== undefined) url.searchParams.set(key, String(value)) } - if (this.resourceMode() === 'public') url.searchParams.set('resource_urls', 'public') + url.searchParams.set('resource_urls', this.resourceMode()) return url.toString() } diff --git a/packages/dsh-weknora/test/client.test.mjs b/packages/dsh-weknora/test/client.test.mjs index 8a03566bab4..bed6a7bc5ab 100644 --- a/packages/dsh-weknora/test/client.test.mjs +++ b/packages/dsh-weknora/test/client.test.mjs @@ -3,7 +3,7 @@ import { after, test } from 'node:test' import { WeknoraApiError, WeknoraClient } from '../dist/client.js' import { resolveConfig } from '../dist/config.js' -import { startMockWeknora } from './helpers/mock-weknora.mjs' +import { ARCH_HANDLE, startMockWeknora } from './helpers/mock-weknora.mjs' const never = new AbortController().signal @@ -56,20 +56,48 @@ test('resourceUrls defaults to public so figures are loadable', async () => { assert.equal(mock.requests.at(-1).query.resource_urls, 'public') }) -test('handle mode omits the resource_urls query parameter', async () => { - const { mock, client } = await harness({ resourceUrls: 'handle' }) +test('handle mode explicitly overrides the deployment resource URL mode', async () => { + const { mock, client } = await harness({ resourceUrls: 'handle' }, { defaultResourceUrls: 'public' }) await client.listKnowledgeBases(never) - assert.equal(mock.requests.at(-1).query.resource_urls, undefined) + assert.equal(mock.requests.at(-1).query.resource_urls, 'handle') }) test('a knowledge-base-restricted key falls back from public URLs to handles', async () => { - const { mock, client } = await harness({}, { forbidPublicResourceUrls: true }) - const bases = await client.listKnowledgeBases(never) - assert.equal(bases.length, 2) + const { mock, client } = await harness({}, { forbidPublicResourceUrls: true, defaultResourceUrls: 'public' }) + const input = { query: '系统架构', knowledgeBaseIds: ['kb-product'], knowledgeIds: [] } + const results = await client.search(input, never) + assert.ok(results.some(result => result.content.includes(ARCH_HANDLE))) assert.equal(mock.requests[0].query.resource_urls, 'public') - assert.equal(mock.requests[1].query.resource_urls, undefined) - await client.listKnowledgeBases(never) - assert.equal(mock.requests.at(-1).query.resource_urls, undefined, 'later calls must not retry public mode') + assert.equal(mock.requests[1].query.resource_urls, 'handle') + await client.search(input, never) + assert.equal(mock.requests.at(-1).query.resource_urls, 'handle', 'later calls must not retry public mode') +}) + +test('streamed answers retry with explicit handles after public mode is refused', async () => { + const { mock, client } = await harness({}, { forbidPublicResourceUrls: true, defaultResourceUrls: 'public' }) + const answer = await client.ask( + { sessionId: 's1', query: '系统架构', knowledgeBaseIds: ['kb-product'], agentId: undefined, webSearch: false }, + never, + ) + assert.equal(mock.requests[0].query.resource_urls, 'public') + assert.equal(mock.requests[1].query.resource_urls, 'handle') + assert.ok(answer.references.some(reference => reference.content.includes(ARCH_HANDLE))) +}) + +test('handle mode keeps resource handles in search results and streamed citations', async () => { + const { mock, client } = await harness({ resourceUrls: 'handle' }, { defaultResourceUrls: 'public' }) + const results = await client.search( + { query: '系统架构', knowledgeBaseIds: ['kb-product'], knowledgeIds: [] }, + never, + ) + assert.ok(results.some(result => result.content.includes(ARCH_HANDLE))) + assert.equal(mock.requests.at(-1).query.resource_urls, 'handle') + const answer = await client.ask( + { sessionId: 's1', query: '系统架构', knowledgeBaseIds: ['kb-product'], agentId: undefined, webSearch: false }, + never, + ) + assert.ok(answer.references.some(reference => reference.content.includes(ARCH_HANDLE))) + assert.equal(mock.requests.at(-1).query.resource_urls, 'handle') }) test('chunk pages carry their pagination envelope', async () => { diff --git a/packages/dsh-weknora/test/helpers/mock-weknora.mjs b/packages/dsh-weknora/test/helpers/mock-weknora.mjs index 9ce3eb8c936..a6cf4453229 100644 --- a/packages/dsh-weknora/test/helpers/mock-weknora.mjs +++ b/packages/dsh-weknora/test/helpers/mock-weknora.mjs @@ -142,6 +142,7 @@ function answerFor(query, results) { * @param options.streamTruncated - end the chat stream mid-answer, with no `complete`. * @param options.forbidPublicResourceUrls - reject `resource_urls=public` with the * 403 WeKnora returns for a knowledge-base-restricted API key. + * @param options.defaultResourceUrls - the deployment's mode when the caller omits the query. * @returns the base URL, the recorded requests, and a close function. */ export async function startMockWeknora(options = {}) { @@ -181,7 +182,11 @@ export async function startMockWeknora(options = {}) { return } - if (options.forbidPublicResourceUrls === true && url.searchParams.get('resource_urls') === 'public') { + const publicMode = (url.searchParams.get('resource_urls') ?? options.defaultResourceUrls) === 'public' + + const resolvesResources = url.pathname === '/api/v1/knowledge-search' + || /^\/api\/v1\/(knowledge-chat|agent-chat)\//.test(url.pathname) + if (options.forbidPublicResourceUrls === true && publicMode && resolvesResources) { json(403, { success: false, error: { message: 'resource_urls=public is not available for a knowledge-base-restricted API key' }, @@ -189,8 +194,6 @@ export async function startMockWeknora(options = {}) { return } - const publicMode = url.searchParams.get('resource_urls') === 'public' - const forced = failures.get(url.pathname) if (forced !== undefined) { json(forced, { success: false, error: { message: `forced failure for ${url.pathname}` } })