Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/resource_urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
168 changes: 168 additions & 0 deletions client/resource_urls_http_test.go
Original file line number Diff line number Diff line change
@@ -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 {

Check failure on line 65 in client/resource_urls_http_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (client)

The line is 134 characters long, which exceeds the maximum of 120 characters. (lll)
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 {

Check failure on line 76 in client/resource_urls_http_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (client)

The line is 142 characters long, which exceeds the maximum of 120 characters. (lll)
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})

Check failure on line 122 in client/resource_urls_http_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (client)

The line is 128 characters long, which exceeds the maximum of 120 characters. (lll)
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)

Check failure on line 127 in client/resource_urls_http_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (client)

The line is 156 characters long, which exceeds the maximum of 120 characters. (lll)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": []map[string]any{{"content": content}}})

Check failure on line 131 in client/resource_urls_http_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (client)

The line is 130 characters long, which exceeds the maximum of 120 characters. (lll)
}))
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)
}
})
}
}
}
4 changes: 2 additions & 2 deletions client/resource_urls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
2 changes: 1 addition & 1 deletion packages/dsh-weknora/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
48 changes: 38 additions & 10 deletions packages/dsh-weknora/test/client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 () => {
Expand Down
9 changes: 6 additions & 3 deletions packages/dsh-weknora/test/helpers/mock-weknora.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -181,16 +182,18 @@ 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' },
})
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}` } })
Expand Down
Loading